You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Extension is always via `...spread`. 100% consistent across all 27 registry-based composables. [intent:147]
328
328
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 |
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 })`|
| 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.
**Do not apply to.** Configuration that is fixed at construction time — `namespace`, `events`, `adapter`. Those stay plain `T`. [intent:133]
508
508
509
-
### 4.4 The `reactive: true` footgun
509
+
### 4.4 Registry reactivity
510
510
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 deptracking 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]
512
512
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]
514
514
515
-
```ts
516
-
// Wrong — reactive: true breaks v-for dep tracking through values() cache
| 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.
- You only need one derived value. `toRef(() => registry.size)` is cheaper.
665
666
- You need deep reactivity through nested ticket properties. Pass `{ deep: true }` (opts into full `reactive()`), but prefer `toRef` over each nested property instead.
666
667
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]
0 commit comments