* feat: move notion docs to standards folder * fix: remove skills mention (automatic now)
191 lines
6.9 KiB
Markdown
191 lines
6.9 KiB
Markdown
- [Dependency Injection](#dependency-injection)
|
||
- [The `createContext` Factory](#the-createcontext-factory)
|
||
- [When to Use DI](#when-to-use-di)
|
||
- [Platform Abstraction (Primary Use Case)](#platform-abstraction-primary-use-case)
|
||
- [Page-Level Context](#page-level-context)
|
||
- [Creating a New Provider](#creating-a-new-provider)
|
||
- [1. Define the interface in `packages/ui/src/providers/`](#1-define-the-interface-in-packagesuisrcproviders)
|
||
- [2. For complex platform-specific logic, use an abstract class](#2-for-complex-platform-specific-logic-use-an-abstract-class)
|
||
- [Wiring Up Providers](#wiring-up-providers)
|
||
- [App Frontend (Tauri)](#app-frontend-tauri)
|
||
- [Website Frontend (Nuxt)](#website-frontend-nuxt)
|
||
- [Consuming Providers](#consuming-providers)
|
||
- [When NOT to Use DI](#when-not-to-use-di)
|
||
- [Existing Providers](#existing-providers)
|
||
- [Key Files](#key-files)
|
||
|
||
# Dependency Injection
|
||
|
||
Modrinth uses a lightweight DI layer built on Vue's `provide`/`inject` for sharing platform-specific capabilities and page-level state across shared UI components.
|
||
|
||
## The `createContext` Factory
|
||
|
||
All providers are defined using `createContext` from `packages/ui/src/providers/index.ts` (adapted from Reka UI). It produces a typed `[inject, provide]` tuple:
|
||
|
||
```ts
|
||
import { createContext } from '@modrinth/ui'
|
||
|
||
interface MyContext {
|
||
someValue: Ref<string>
|
||
doSomething: () => void
|
||
}
|
||
|
||
export const [injectMyContext, provideMyContext] = createContext<MyContext>('MyComponent')
|
||
```
|
||
|
||
- **`provideMyContext(value)`** — call in a parent component's `setup()`.
|
||
- **`injectMyContext()`** — call in any descendant's `setup()`. Throws if never provided.
|
||
- **`injectMyContext(null)`** — returns `null` instead of throwing (for optional contexts).
|
||
|
||
## When to Use DI
|
||
|
||
Use DI when:
|
||
- **The same interface needs different implementations** depending on the platform (web vs desktop app).
|
||
- **Deeply nested components** need access to shared page-level state without prop drilling through 3+ levels.
|
||
|
||
### Platform Abstraction (Primary Use Case)
|
||
|
||
`packages/ui` components need capabilities that each frontend fulfils differently:
|
||
|
||
| Provider | App Frontend | Website Frontend |
|
||
| ------------- | -------------------------------- | ------------------------------ |
|
||
| API client | Tauri IPC client | REST fetch client |
|
||
| Notifications | `ref()` state + app window mgmt | `useState()` for SSR hydration |
|
||
| File picker | Native Tauri dialogs | Browser file inputs |
|
||
| Tags | Tauri commands | Nuxt server state |
|
||
| Page context | `sidebar: true`, ad window hooks | `sidebar: false`, no ads |
|
||
|
||
### Page-Level Context
|
||
|
||
Sharing data between a page and deeply nested children — e.g. project page data consumed by sidebar, header, and version components.
|
||
|
||
## Creating a New Provider
|
||
|
||
### 1. Define the interface in `packages/ui/src/providers/`
|
||
|
||
```ts
|
||
// packages/ui/src/providers/my-feature.ts
|
||
import type { Ref } from 'vue'
|
||
import { createContext } from '.'
|
||
|
||
export interface MyFeatureContext {
|
||
items: Ref<Item[]>
|
||
addItem: (item: Item) => Promise<void>
|
||
removeItem: (id: string) => Promise<void>
|
||
}
|
||
|
||
export const [injectMyFeature, provideMyFeature] = createContext<MyFeatureContext>('MyFeature')
|
||
```
|
||
|
||
Re-export from the barrel file (`packages/ui/src/providers/index.ts`).
|
||
|
||
### 2. For complex platform-specific logic, use an abstract class
|
||
|
||
```ts
|
||
export abstract class AbstractMyFeatureManager {
|
||
abstract items: Ref<Item[]>
|
||
abstract addItem(item: Item): Promise<void>
|
||
|
||
// Shared logic lives on the base class
|
||
handleError(err: unknown) {
|
||
console.error(err)
|
||
}
|
||
}
|
||
|
||
export const [injectMyFeature, provideMyFeature] =
|
||
createContext<AbstractMyFeatureManager>('MyFeature')
|
||
```
|
||
|
||
See `AbstractWebNotificationManager` in `packages/ui/src/providers/web-notifications.ts` for a real example.
|
||
|
||
## Wiring Up Providers
|
||
|
||
### App Frontend (Tauri)
|
||
|
||
Create a setup function in `apps/app-frontend/src/providers/setup/`:
|
||
|
||
```ts
|
||
// apps/app-frontend/src/providers/setup/my-feature.ts
|
||
import { ref } from 'vue'
|
||
import { provideMyFeature } from '@modrinth/ui'
|
||
|
||
export function setupMyFeatureProvider() {
|
||
const items = ref<Item[]>([])
|
||
|
||
provideMyFeature({
|
||
items,
|
||
addItem: async (item) => {
|
||
await invoke('add_item', { item })
|
||
items.value.push(item)
|
||
},
|
||
removeItem: async (id) => {
|
||
await invoke('remove_item', { id })
|
||
items.value = items.value.filter(i => i.id !== id)
|
||
},
|
||
})
|
||
}
|
||
```
|
||
|
||
Register it in `apps/app-frontend/src/providers/setup.ts`, which is called from `App.vue`'s `setup()`.
|
||
|
||
### Website Frontend (Nuxt)
|
||
|
||
Provide directly in `apps/frontend/src/app.vue`, using Nuxt's `useState()` where SSR hydration is needed:
|
||
|
||
```ts
|
||
provideMyFeature({
|
||
items: useState<Item[]>('my-feature-items', () => []),
|
||
addItem: async (item) => {
|
||
await $fetch('/api/items', { method: 'POST', body: item })
|
||
},
|
||
removeItem: async (id) => {
|
||
await $fetch(`/api/items/${id}`, { method: 'DELETE' })
|
||
},
|
||
})
|
||
```
|
||
|
||
## Consuming Providers
|
||
|
||
In any component across `packages/ui`, `apps/frontend`, or `apps/app-frontend`:
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import { injectMyFeature } from '@modrinth/ui'
|
||
|
||
const { items, addItem } = injectMyFeature()
|
||
</script>
|
||
|
||
<template>
|
||
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
|
||
<button @click="addItem({ id: '1', name: 'New' })">Add</button>
|
||
</template>
|
||
```
|
||
|
||
## When NOT to Use DI
|
||
|
||
Default to props and emits. DI adds indirection — only use it with a concrete reason.
|
||
|
||
- **Parent to direct child** — use props.
|
||
- **Data only exists in one frontend** — keep context local to that app, not in `packages/ui`.
|
||
- **Shallow prop drilling (1–2 levels)** — passing through one intermediate is fine.
|
||
- **Component-local state** — use `ref()` / `reactive()` locally.
|
||
|
||
## Existing Providers
|
||
|
||
| Provider | File | Purpose |
|
||
| ---------------------------- | -------------------------------- | ------------------------------ |
|
||
| `provideModrinthClient` | `providers/api-client.ts` | API client instance |
|
||
| `provideNotificationManager` | `providers/web-notifications.ts` | Notification management |
|
||
| `providePageContext` | `providers/page-context.ts` | Page config (sidebar, ads) |
|
||
| `provideProjectPageContext` | `providers/project-page.ts` | Project page state + mutations |
|
||
| `provideServerContext` | `providers/server-context.ts` | Server hosting state |
|
||
| `provideUserPageContext` | `providers/user-page.ts` | User page state |
|
||
|
||
## Key Files
|
||
|
||
- `packages/ui/src/providers/index.ts` — `createContext` factory + barrel exports
|
||
- `packages/ui/src/providers/*.ts` — Provider definitions
|
||
- `apps/frontend/src/app.vue` — Nuxt root provider setup
|
||
- `apps/app-frontend/src/App.vue` — Tauri root provider setup
|
||
- `apps/app-frontend/src/providers/setup/` — App provider setup functions
|