Skip to content

Commit 86e0ff2

Browse files
committed
fix(createRegistry): batch events in unregister/offboard, fix lazy reindex in browse
- unregister() now uses queueEmit() for batch consistency - browse() always reindexes when needsReindex is true - offboard() wrapped in batch() for atomic cache invalidation - add Extensible<T> type for extensible event names with autocomplete - add reactive option tests (5 tests) and seek('last', from) tests (3 tests) - add SSR dev warning to useId() outside component context - expand CLAUDE.md with built-in types documentation
1 parent 0e5afec commit 86e0ff2

File tree

5 files changed

+262
-38
lines changed

5 files changed

+262
-38
lines changed

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ Vue 3 headless UI primitives and composables. Unstyled, logic-focused building b
1919
| `range(length, start)` | Create sequential number array |
2020
| `debounce(fn, delay)` | Debounce with `.clear()` and `.immediate()` |
2121

22+
### Use Built-in Types (`#v0/types`)
23+
24+
| Type | Purpose |
25+
|------|---------|
26+
| `ID` | Identifier type (`string \| number`) for registry tickets |
27+
| `Extensible<T>` | Preserves string literal autocomplete while allowing arbitrary strings |
28+
| `MaybeArray<T>` | Union accepting single value or array (`T \| T[]`) |
29+
| `DeepPartial<T>` | Recursively makes all properties optional |
30+
| `Activation` | Keyboard activation mode (`'automatic' \| 'manual'`) |
31+
2232
### Use Built-in Constants (`#v0/constants/globals`)
2333

2434
| Constant | Purpose |

packages/0/src/composables/createRegistry/index.test.ts

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
// Utilities
21
import { describe, expect, it, vi } from 'vitest'
32

4-
// Composables
3+
// Utilities
4+
import { isReactive, nextTick, watchEffect } from 'vue'
5+
56
import { createRegistry, createRegistryContext } from './index'
67

78
describe('createRegistry', () => {
@@ -406,6 +407,17 @@ describe('createRegistry', () => {
406407
warnSpy.mockRestore()
407408
})
408409

410+
it('should warn when attempting to remove listener without events enabled', () => {
411+
const registry = createRegistry({ events: false })
412+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
413+
414+
registry.off('register:ticket', vi.fn())
415+
416+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Events are disabled'))
417+
418+
warnSpy.mockRestore()
419+
})
420+
409421
it('should support multiple listeners for same event', () => {
410422
const registry = createRegistry({ events: true })
411423
const listener1 = vi.fn()
@@ -567,6 +579,36 @@ describe('createRegistry', () => {
567579
const found2 = registry.seek('first', -100)
568580
expect(found2?.id).toBe('item-1')
569581
})
582+
583+
it('should seek last from specific index', () => {
584+
const registry = createRegistry()
585+
registry.register({ id: 'item-1', value: 'a' })
586+
registry.register({ id: 'item-2', value: 'b' })
587+
registry.register({ id: 'item-3', value: 'c' })
588+
589+
const found = registry.seek('last', 1)
590+
expect(found?.id).toBe('item-2')
591+
})
592+
593+
it('should seek last with predicate from offset', () => {
594+
const registry = createRegistry()
595+
registry.register({ id: 'item-1', value: 'apple' })
596+
registry.register({ id: 'item-2', value: 'banana' })
597+
registry.register({ id: 'item-3', value: 'apricot' })
598+
599+
const found = registry.seek('last', 2, t => (t.value as string).startsWith('a'))
600+
expect(found?.id).toBe('item-3')
601+
})
602+
603+
it('should seek last from beginning when from is 0', () => {
604+
const registry = createRegistry()
605+
registry.register({ id: 'item-1', value: 'a' })
606+
registry.register({ id: 'item-2', value: 'b' })
607+
registry.register({ id: 'item-3', value: 'c' })
608+
609+
const found = registry.seek('last', 0)
610+
expect(found?.id).toBe('item-1')
611+
})
570612
})
571613

572614
describe('dispose functionality', () => {
@@ -870,3 +912,67 @@ describe('createRegistryContext', () => {
870912
expect(context2.size).toBe(0)
871913
})
872914
})
915+
916+
describe('reactive option', () => {
917+
it('should create reactive collection when enabled', () => {
918+
const registry = createRegistry({ reactive: true })
919+
expect(isReactive(registry.collection)).toBe(true)
920+
})
921+
922+
it('should not create reactive collection when disabled', () => {
923+
const registry = createRegistry({ reactive: false })
924+
expect(isReactive(registry.collection)).toBe(false)
925+
})
926+
927+
it('should create reactive tickets when enabled', () => {
928+
const registry = createRegistry({ reactive: true })
929+
const ticket = registry.register({ id: 'test' })
930+
expect(isReactive(ticket)).toBe(true)
931+
})
932+
933+
it('should not create reactive tickets when disabled', () => {
934+
const registry = createRegistry({ reactive: false })
935+
const ticket = registry.register({ id: 'test' })
936+
expect(isReactive(ticket)).toBe(false)
937+
})
938+
939+
it('should trigger reactivity on collection changes', async () => {
940+
const registry = createRegistry({ reactive: true })
941+
const sizes: number[] = []
942+
943+
watchEffect(() => {
944+
sizes.push(registry.collection.size)
945+
})
946+
947+
expect(sizes).toEqual([0])
948+
949+
registry.register({ id: 'item-1' })
950+
await nextTick()
951+
expect(sizes).toEqual([0, 1])
952+
953+
registry.register({ id: 'item-2' })
954+
await nextTick()
955+
expect(sizes).toEqual([0, 1, 2])
956+
957+
registry.unregister('item-1')
958+
await nextTick()
959+
expect(sizes).toEqual([0, 1, 2, 1])
960+
})
961+
962+
it('should trigger reactivity when getting updated ticket from collection', async () => {
963+
const registry = createRegistry({ reactive: true })
964+
registry.register({ id: 'test', value: 'initial' })
965+
const values: unknown[] = []
966+
967+
watchEffect(() => {
968+
const ticket = registry.get('test')
969+
values.push(ticket?.value)
970+
})
971+
972+
expect(values).toEqual(['initial'])
973+
974+
registry.upsert('test', { value: 'updated' })
975+
await nextTick()
976+
expect(values).toEqual(['initial', 'updated'])
977+
})
978+
})

packages/0/src/composables/createRegistry/index.ts

Lines changed: 96 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
2-
* @module useRegistry
2+
* @module createRegistry
33
*
4-
* @see https://0.vuetifyjs.com/composables/registration/use-registry
4+
* @see https://0.vuetifyjs.com/composables/registration/create-registry
55
*
66
* @remarks
77
* A foundational composable for managing collections of items (tickets) with:
@@ -17,7 +17,7 @@
1717
*/
1818

1919
// Foundational
20-
import { createContext } from '#v0/composables/createContext'
20+
import { createContext, useContext } from '#v0/composables/createContext'
2121
import { createTrinity } from '#v0/composables/createTrinity'
2222

2323
// Composables
@@ -29,7 +29,7 @@ import { shallowReactive } from 'vue'
2929

3030
// Types
3131
import type { ContextTrinity } from '#v0/composables/createTrinity'
32-
import type { ID } from '#v0/types'
32+
import type { Extensible, ID } from '#v0/types'
3333
import type { App } from 'vue'
3434

3535
export interface RegistryTicket<V = unknown> {
@@ -51,8 +51,43 @@ export interface RegistryTicket<V = unknown> {
5151
valueIsIndex: boolean
5252
}
5353

54+
/** Valid event names for registry operations */
55+
export type RegistryEventName =
56+
| 'register:ticket'
57+
| 'unregister:ticket'
58+
| 'update:ticket'
59+
| 'clear:registry'
60+
| 'reindex:registry'
61+
62+
/** Maps event names to their payload types */
63+
export type RegistryEventMap<Z extends RegistryTicket> = {
64+
'register:ticket': Z
65+
'unregister:ticket': Z
66+
'update:ticket': Z
67+
'clear:registry': undefined
68+
'reindex:registry': undefined
69+
}
70+
5471
/** Callback signature for registry event listeners */
55-
export type RegistryEventCallback = (data: unknown) => void
72+
export type RegistryEventCallback<
73+
Z extends RegistryTicket = RegistryTicket,
74+
K extends RegistryEventName = RegistryEventName,
75+
> = (data: RegistryEventMap<Z>[K]) => void
76+
77+
/** Resolves callback type: typed for known events, unknown for custom events */
78+
type EventHandler<Z extends RegistryTicket, K extends string> =
79+
K extends RegistryEventName
80+
? (data: RegistryEventMap<Z>[K]) => void
81+
: (data: unknown) => void
82+
83+
/** Resolves payload type: typed for known events, unknown for custom events */
84+
type EventPayload<Z extends RegistryTicket, K extends string> =
85+
K extends RegistryEventName
86+
? RegistryEventMap<Z>[K]
87+
: unknown
88+
89+
/** Internal callback type for event listeners storage */
90+
type InternalEventCallback = (data: unknown) => void
5691

5792
export interface RegistryContext<Z extends RegistryTicket = RegistryTicket> {
5893
/**
@@ -150,7 +185,7 @@ export interface RegistryContext<Z extends RegistryTicket = RegistryTicket> {
150185
* const unique = registry.browse('unique-value') // ['ticket-3']
151186
* ```
152187
*/
153-
browse: (value: unknown) => ID[] | undefined
188+
browse: (value: Z['value']) => ID[] | undefined
154189
/**
155190
* lookup a ticket by index number
156191
*
@@ -377,7 +412,7 @@ export interface RegistryContext<Z extends RegistryTicket = RegistryTicket> {
377412
* registry.register({ id: 'ticket-id' }) // Console: Ticket registered: { id: 'ticket-id', ... }
378413
* ```
379414
*/
380-
on: (event: string, cb: RegistryEventCallback) => void
415+
on: <K extends Extensible<RegistryEventName>>(event: K, cb: EventHandler<Z, K>) => void
381416
/**
382417
* Stop listening for registry events
383418
*
@@ -407,7 +442,7 @@ export interface RegistryContext<Z extends RegistryTicket = RegistryTicket> {
407442
* })
408443
* ```
409444
*/
410-
off: (event: string, cb: RegistryEventCallback) => void
445+
off: <K extends Extensible<RegistryEventName>>(event: K, cb: EventHandler<Z, K>) => void
411446
/**
412447
* Emit an event with data
413448
*
@@ -430,7 +465,7 @@ export interface RegistryContext<Z extends RegistryTicket = RegistryTicket> {
430465
* registry.emit('custom-event', { message: 'Hello, World!' }) // Console: Custom event received: { message: 'Hello, World!' }
431466
* ```
432467
*/
433-
emit: (event: string, data: unknown) => void
468+
emit: <K extends Extensible<RegistryEventName>>(event: K, data: EventPayload<Z, K>) => void
434469
/**
435470
* Clears the registry and removes all listeners
436471
*
@@ -632,7 +667,7 @@ export function createRegistry<
632667
const catalog = new Map<unknown, ID[]>()
633668
const directory = new Map<number, ID>()
634669
const cache = new Map<'keys' | 'values' | 'entries', unknown[]>()
635-
const listeners = new Map<string, Set<RegistryEventCallback>>()
670+
const listeners = new Map<string, Set<InternalEventCallback>>()
636671

637672
let indexDependentCount = 0
638673
let needsReindex = false
@@ -647,7 +682,7 @@ export function createRegistry<
647682
for (const cb of cbs) cb(data)
648683
}
649684

650-
function on (event: string, cb: RegistryEventCallback) {
685+
function on (event: string, cb: InternalEventCallback) {
651686
if (!events) {
652687
logger.warn(`Events are disabled. Initialize with \`createRegistry({ events: true })\` to enable.`)
653688
return
@@ -657,7 +692,7 @@ export function createRegistry<
657692
listeners.get(event)!.add(cb)
658693
}
659694

660-
function off (event: string, cb: RegistryEventCallback) {
695+
function off (event: string, cb: InternalEventCallback) {
661696
if (!events) {
662697
logger.warn(`Events are disabled. Initialize with \`createRegistry({ events: true })\` to enable.`)
663698
return
@@ -723,9 +758,7 @@ export function createRegistry<
723758
}
724759

725760
function browse (value: unknown) {
726-
if (indexDependentCount > 0 && needsReindex) {
727-
reindex()
728-
}
761+
if (needsReindex) reindex()
729762
return catalog.get(value)
730763
}
731764

@@ -933,7 +966,7 @@ export function createRegistry<
933966
const willReindex = indexDependentCount > 0 && ticket.index < collection.size
934967
if (!willReindex) invalidate()
935968

936-
emit('unregister:ticket', ticket)
969+
queueEmit('unregister:ticket', ticket)
937970

938971
minDirtyIndex = Math.min(minDirtyIndex, ticket.index)
939972
if (willReindex) {
@@ -944,32 +977,32 @@ export function createRegistry<
944977
}
945978

946979
function offboard (ids: ID[]) {
947-
const removed: Z[] = []
980+
batch(() => {
981+
const removed: Z[] = []
948982

949-
for (const id of ids) {
950-
const ticket = collection.get(id)
951-
if (!ticket) continue
983+
for (const id of ids) {
984+
const ticket = collection.get(id)
985+
if (!ticket) continue
952986

953-
if (ticket.valueIsIndex) {
954-
indexDependentCount--
955-
}
956-
957-
minDirtyIndex = Math.min(minDirtyIndex, ticket.index)
958-
collection.delete(ticket.id)
959-
directory.delete(ticket.index)
960-
unassign(ticket.value, ticket.id)
961-
removed.push(ticket)
962-
}
987+
if (ticket.valueIsIndex) {
988+
indexDependentCount--
989+
}
963990

964-
if (removed.length === 0) return
991+
minDirtyIndex = Math.min(minDirtyIndex, ticket.index)
992+
collection.delete(ticket.id)
993+
directory.delete(ticket.index)
994+
unassign(ticket.value, ticket.id)
995+
removed.push(ticket)
996+
}
965997

966-
invalidate()
998+
if (removed.length === 0) return
967999

968-
for (const ticket of removed) {
969-
queueEmit('unregister:ticket', ticket)
970-
}
1000+
for (const ticket of removed) {
1001+
queueEmit('unregister:ticket', ticket)
1002+
}
9711003

972-
needsReindex = true
1004+
needsReindex = true
1005+
})
9731006
}
9741007

9751008
function seek (
@@ -1080,3 +1113,30 @@ export function createRegistryContext<
10801113

10811114
return createTrinity<E>(useRegistryContext, provideRegistryContext, context)
10821115
}
1116+
1117+
/**
1118+
* Uses an existing registry from context.
1119+
*
1120+
* @param namespace The namespace for the registry context. Defaults to `'v0:registry'`.
1121+
* @template Z The type of registry ticket that extends RegistryTicket.
1122+
* @template E The type of registry context that extends RegistryContext<Z>.
1123+
* @returns The registry instance.
1124+
*
1125+
* @see https://0.vuetifyjs.com/composables/registration/create-registry#use-registry
1126+
*
1127+
* @example
1128+
* ```ts
1129+
* import { useRegistry } from '@vuetify/v0'
1130+
*
1131+
* // In a child component (after provideRegistry was called by an ancestor):
1132+
* const registry = useRegistry()
1133+
*
1134+
* registry.register({ id: 'item-1', value: 'Value 1' })
1135+
* ```
1136+
*/
1137+
export function useRegistry<
1138+
Z extends RegistryTicket = RegistryTicket,
1139+
E extends RegistryContext<Z> = RegistryContext<Z>,
1140+
> (namespace = 'v0:registry'): E {
1141+
return useContext<E>(namespace)
1142+
}

0 commit comments

Comments
 (0)