feat(ui-slots): bind inject hooks compartments into use<Name> selector hooks

Registrant-private reactive facts previously reached components as raw
observables that each component subscribed by hand (InputBar notices/
lexicon via uSES, SettingsRoot via a version/subscribe/getter triple).
The inject face now carries a reserved hooks compartment of bare
sources; the renderer binds each into a use<Name> selector hook through
the same machinery as the provide channel, so components consume
useNotices/useLexicon/useSections and never see a subscription
primitive. InputBar and SettingsRoot are the first two consumers.
This commit is contained in:
imccyu
2026-07-28 12:08:24 +08:00
parent b7f3cd3d78
commit b5168bbf86
20 changed files with 185 additions and 89 deletions
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useEffect, useState } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
@@ -22,9 +23,9 @@ function mount({
{ id: 'models', order: 10, label: 'Models' },
],
}: { wide?: boolean; rows?: Row[] } = {}) {
// Mutable row store standing in for the ledger; bump() plays a change.
// Mutable row source standing in for the bound useSections hook; bump()
// plays a ledger change through the same observable contract.
let current = rows
let version = 0
const listeners = new Set<() => void>()
const renderSlot = vi.fn(
((key: string, _owner: unknown, opts?: { only?: string }) => {
@@ -38,19 +39,21 @@ function mount({
useSessions: unusedHook,
useWorkspaces: unusedHook,
wide,
sectionsVersion: () => version,
subscribeSections: (listener) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
useSections: (select) => {
const [, force] = useState(0)
useEffect(() => {
const listener = () => { force(n => n + 1) }
listeners.add(listener)
return () => { listeners.delete(listener) }
}, [])
return select(current)
},
sections: () => current,
renderSlot,
}
const view = render(<SettingsRoot {...props} />)
const bump = (next: Row[]) => {
act(() => {
current = next
version += 1
for (const fn of [...listeners]) fn()
})
}