Skip to content

Commit ff4d6c4

Browse files
authored
docs(rules,philosophy): codify reactive-default convention for plugins (#213)
- `.claude/rules/composables.md`: new section "Plugins and Reactive Defaults" covering the primitive/plugin tiers, when to pass `reactive: true` to an internal registry, the two-registry architecture, and how `reactive: true` and `useProxyRegistry` complement each other. - `PHILOSOPHY.md §4.4`: rewrite from "the reactive: true footgun" to "Registry reactivity". The footgun described (values() cache dropping Vue dep tracking) was closed in #209 — that section was actively teaching the wrong mental model through #210, #211, and #212. New text positions `reactive: true` and `useProxyRegistry` as two valid complementary options. - `PHILOSOPHY.md §6.7`: revise the closing warning that said "do not substitute reactive: true on the registry itself" — same reason. Refs #208 #209 #210 #211 #212
1 parent e61744a commit ff4d6c4

2 files changed

Lines changed: 67 additions & 12 deletions

File tree

.claude/rules/composables.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,60 @@ Layer 2: Complex orchestrators
326326

327327
Extension is always via `...spread`. 100% consistent across all 27 registry-based composables. [intent:147]
328328

329+
## Plugins and Reactive Defaults
330+
331+
Scope: how a plugin (or a high-level factory composable) that wraps a registry should configure it — specifically, when to pass `reactive: true` internally vs. leave it off.
332+
333+
### Two tiers
334+
335+
- **Primitives**`createRegistry`, `createModel`, `createSelection`, `createSingle`, `createGroup`, `createStep`. Expose the `reactive` switch. Callers opt in based on their use case.
336+
- **Plugins**`useTheme`, `useLocale`, `useFeatures`, `useNotifications`, `useLogger`, `usePermissions`. App-level services created via `createPluginContext`. Ship a named user-facing contract (e.g. "the theme updates reactively"). Bake reactivity in; consumers never see the registry underneath.
337+
338+
High-level factory composables that aren't plugins but expose a similar contract — `createForm`, `createInput`, `createBreadcrumbs` — follow the same convention as plugins. The distinction that matters for this section isn't plugin-vs-factory, it's whether the composable ships a Vue-reactive contract or leaves the reactivity choice to its caller.
339+
340+
### The rule
341+
342+
**Any plugin (or plugin-shaped factory) whose public surface is reachable from a template or a computed passes `reactive: true` to its internal registry.** "Public surface reachable from a template or a computed" means any of:
343+
344+
- `registry.values()` / `keys()` / `entries()` iteration (directly or via `for (const x of …)` inside a computed)
345+
- `registry.size` exposed as a getter
346+
- `registry.get(id)` called from consumer code or from a wrapper helper (`features.variation(id)`, `theme.colors` resolving through ticket value)
347+
- Per-ticket field reads when consumers hold ticket references
348+
349+
### Why
350+
351+
Users of `useTheme()` expect `theme.colors` to track, `v-for="name in theme.keys()"` to update, and `theme.upsert()` to propagate. Making any of that contingent on a plugin-configuration flag they've never heard of is a footgun. The cost of `reactive: true` on a bounded UI-adjacent registry is negligible; the cost of leaking the opt-in up to consumers is a class of latent bugs (#208 was one of them).
352+
353+
### Two-registry architecture
354+
355+
Many plugins keep two internal collections — a small reactive ID registry and a large non-reactive lookup table:
356+
357+
| Role | Reactive? | Example |
358+
|---|---|---|
359+
| Small ID registry (who's registered, who's selected) | `reactive: true` | `useTheme`'s theme registry, `useLocale`'s locale registry, `useFeatures`'s flag registry |
360+
| Large lookup table (palette tokens, translation strings) | default (non-reactive) | `createTokens({ palette, ...themes })`, `createTokens(messages)` |
361+
362+
The big table stays cheap (plain `Map`, O(1) lookups). Runtime mutations that need reactivity flow through the small registry (e.g. replace a theme's `value` object via `upsert`), not through the tokens. Tokens provide alias dereferencing at read time.
363+
364+
This split is why `useTheme` and `useLocale` work fine even though their reactive registries are bounded to 2–20 entries — the bulk data never needed reactivity in the first place.
365+
366+
### Escape hatch for unbounded collections
367+
368+
For plugins whose registries can grow without bound (notifications, data tables, logs, queues), `reactive: true` is still the right default but consider exposing an explicit opt-out on the plugin options. The shape is "bake in reactive behavior, let the consumer turn it off if they hit a scale wall." `useNotifications` is the canonical example.
369+
370+
### `useProxyRegistry` vs `reactive: true`
371+
372+
Both deliver reactive iteration; they're complementary, not competing. Pick based on what else you need:
373+
374+
| Want | Use |
375+
|---|---|
376+
| Reactive iteration **plus** per-ticket field mutations via `upsert` | `reactive: true` on the registry |
377+
| Reactive iteration without wrapping each ticket in a proxy | `useProxyRegistry(registry, { events: true })` |
378+
| `{ deep: true }` tracking on registered tickets | `useProxyRegistry(registry, { deep: true })` |
379+
| Reactive snapshot driven by explicit registry events | `useProxyRegistry` |
380+
381+
`createGroup` bakes in `useProxyRegistry` internally for its derived reactive selection state; plugins like `useTheme` bake in `reactive: true` for the ticket-level mutation path. The choice is per-composable, based on which capability the contract needs.
382+
329383
## Scope Guards (PHILOSOPHY §4.6)
330384

331385
| Composable type | Guard |

packages/0/PHILOSOPHY.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -506,21 +506,22 @@ function useFoo (options: UseFooOptions = {}) {
506506

507507
**Do not apply to.** Configuration that is fixed at construction time — `namespace`, `events`, `adapter`. Those stay plain `T`. [intent:133]
508508

509-
### 4.4 The `reactive: true` footgun
509+
### 4.4 Registry reactivity
510510

511-
Do not recommend `reactive: true` on a registry as a way to make `v-for` render reactively over registry values. The option wraps internal state in a `reactive()` proxy, but the `values()` / `keys()` / `entries()` methods cache their results; Vue's dep-tracking breaks across that cache boundary and templates iterating the cached array will not re-render on mutation. [intent:253]
511+
`reactive: true` on a registry wraps the internal collection as `shallowReactive` and each registered ticket as a `shallowReactive` proxy. When this option is set, `values()` / `keys()` / `entries()` skip their result cache and re-iterate on every call, so Vue's dep tracking holds across computed re-runs. Template iteration, `registry.size` reads, `get(id)` reads, and per-ticket field mutations via `upsert` all propagate to consumers. [intent:253]
512512

513-
The correct pattern is `useProxyRegistry(registry, { events: true })`, which exposes `proxy.values` / `proxy.keys` / `proxy.entries` as *properties* on a shallow-reactive object and drives updates from `register:ticket` / `unregister:ticket` / `update:ticket` events. [intent:254]
513+
`useProxyRegistry(registry, { events: true })` exposes `proxy.values` / `proxy.keys` / `proxy.entries` / `proxy.size` as properties on a shallow-reactive object, updated from `register:ticket` / `unregister:ticket` / `update:ticket` / `clear:registry` / `reindex:registry` events. It does not wrap the tickets themselves, and supports `{ deep: true }` for nested tracking. [intent:254]
514514

515-
```ts
516-
// Wrong — reactive: true breaks v-for dep tracking through values() cache
517-
const registry = createRegistry({ reactive: true })
515+
Both are valid and complementary. Pick based on the consumer's actual need:
518516

519-
// Right — useProxyRegistry exposes reactive properties
520-
const registry = createRegistry({ events: true })
521-
const proxy = useProxyRegistry(registry)
522-
// template: <div v-for="v in proxy.values" :key="v.id">
523-
```
517+
| Want | Use |
518+
|---|---|
519+
| Reactive iteration **plus** per-ticket field mutations via `upsert` | `reactive: true` on the registry |
520+
| Reactive iteration without wrapping each ticket in a proxy | `useProxyRegistry(registry, { events: true })` |
521+
| `{ deep: true }` tracking on registered tickets | `useProxyRegistry(registry, { deep: true, events: true })` |
522+
| Explicit event-driven snapshot semantics, no registry-level reactivity | `useProxyRegistry` |
523+
524+
Plugins bake one or the other in internally — see `.claude/rules/composables.md` "Plugins and Reactive Defaults" for the convention. Primitives expose the choice to callers.
524525

525526
### 4.5 Scope cleanup contract
526527

@@ -664,7 +665,7 @@ useProxyModel(context, model, { multiple })
664665
- You only need one derived value. `toRef(() => registry.size)` is cheaper.
665666
- You need deep reactivity through nested ticket properties. Pass `{ deep: true }` (opts into full `reactive()`), but prefer `toRef` over each nested property instead.
666667

667-
**Do not substitute `reactive: true` on the registry itself.** That option breaks template dep tracking through the `values()` cache. See §4.4. [intent:253]
668+
**`reactive: true` vs `useProxyRegistry`.** Both deliver reactive iteration. Choose `reactive: true` on the registry when you also need `upsert`-driven per-ticket field mutations to propagate. Choose `useProxyRegistry` when you want event-driven snapshot semantics, `{ deep: true }` tracking, or reactive iteration without wrapping tickets. See §4.4. [intent:253]
668669

669670
Canonical: `packages/0/src/composables/useProxyRegistry/index.ts`.
670671

0 commit comments

Comments
 (0)