feat(integrations): JS integration registry + useIntegrationRegistry composable#202
feat(integrations): JS integration registry + useIntegrationRegistry composable#202rubenvdlinde wants to merge 1 commit into
Conversation
…composable
Adds the JS-side runtime that consuming Conduction apps will use to
register integration tabs and widgets. The registry is reactive:
subscribers via onChange() are notified whenever a provider is
registered or unregistered, so list views (CnObjectSidebar,
CnDashboardPage, CnDetailPage — landing in the following PR) update
without manual refresh.
Per OpenRegister umbrella change pluggable-integration-registry,
tasks 6.1–6.4. Matches the PHP-side IntegrationRegistry interface so
PHP + JS stay in lockstep:
- register/unregister/list/get/has/resolveWidget/onChange
- sorted by `order` ascending then id (stable rendering)
- AD-13 collision policy: dev throws, prod warns + keeps first
- AD-19 surface fallback: widgetCompact / widgetExpanded /
widgetEntity override widget per surface; absent overrides fall
back to the main `widget` with `surface` prop passed through
Bootstrap-order safety: consuming apps that load before OpenRegister
install a `{ _queue: [], register(e) { this._queue.push(e) } }` stub
on window.OCA.OpenRegister.integrations. When the real registry
loads, installIntegrationRegistry(window) replays the queue.
Files:
- src/integrations/registry.js (new) — createIntegrationRegistry()
factory + default `integrations` singleton +
installIntegrationRegistry(window) + VALID_SURFACES
- src/integrations/index.js (new) — sub-barrel
- src/composables/useIntegrationRegistry.js (new) — Vue 2.7
composable wrapping the singleton in a reactive snapshot; cleans
up subscription on unmount
- src/composables/index.js — re-export new composable
- src/index.js — public exports (integrations,
createIntegrationRegistry, installIntegrationRegistry,
VALID_SURFACES, useIntegrationRegistry)
- CLAUDE.md — new "Pluggable Integration Registry" section under
Composables with registration shape, surface fallback, collision
policy, bootstrap-order safety, and reading-in-components example
- tests/integrations/registry.spec.js (new) — 15 unit tests
- tests/composables/useIntegrationRegistry.spec.js (new) — 4
component-level tests (Vue test-utils mount)
Spec deviation:
Spec called for `listByGroup` — implemented as inline filter on the
snapshot (`integrations.value.filter(p => p.group === 'comms')`).
`resolveWidget(id, surface)` added to encapsulate the AD-19
surface fallback rule so call sites don't have to reimplement it.
Tests:
npx jest tests/integrations tests/composables/useIntegrationRegistry.spec.js
-> 2 suites, 21 tests, 21 passed
Full Jest suite: 972 tests, 4 failing (CnAiChatPanel + CnMapWidget,
pre-existing on origin/beta, unrelated to this change)
| function __resetForTests() { | ||
| providers.clear() | ||
| listeners.clear() | ||
| } |
There was a problem hiding this comment.
[BLOCKER] Module-level singleton leaks across NC apps in same browser tab
The integrations singleton is created once at module evaluation time. Because Nextcloud apps typically share a single webpack bundle evaluation context (or the module is cached by the browser), every app that imports @conduction/nextcloud-vue receives the exact same registry instance. An integration registered by app A is visible when app B renders CnObjectSidebar. This is the foundational design issue that propagates to all stacked PRs.
Fix: require callers to create a per-app registry via createIntegrationRegistry() and provide it through Vue provide/inject rather than relying on the module-level singleton. If a true singleton is intentional, add explicit documentation that it is designed to be a global cross-app registry and guard installIntegrationRegistry to be called only once (currently nothing prevents double installation).
| export function useIntegrationRegistry(registry) { | ||
| const target = registry || defaultRegistry | ||
| const snapshot = ref(target.list()) | ||
|
|
There was a problem hiding this comment.
[BLOCKER] Race: snapshot stale if registration fires synchronously before onChange subscribes
The composable calls target.list() to seed the snapshot, then calls target.onChange(...) to subscribe. If any synchronous code between those two lines calls register() (e.g. via queue drain in installIntegrationRegistry), that registration will be missed in the snapshot and the subscriber will not fire for it.
Fix: subscribe first, then seed:
const snapshot = ref([])
const unsubscribe = target.onChange((next) => { snapshot.value = next })
snapshot.value = target.list() // seed after subscribe| export function installIntegrationRegistry(globalRef) { | ||
| const target = globalRef || (typeof window !== 'undefined' ? window : null) | ||
| if (target === null) { | ||
| return integrations |
There was a problem hiding this comment.
[CONCERN] installIntegrationRegistry silently replaces an already-installed real registry
If installIntegrationRegistry is called twice, the assignment target.OCA.OpenRegister.integrations = integrations still fires, silently replacing whatever was there with a fresh singleton. Any registrations already in the first real registry are discarded.
Fix: add a guard:
if (target.OCA.OpenRegister.integrations === integrations) {
return integrations // already installed
}| } | ||
| return a.id.localeCompare(b.id) | ||
| }) | ||
| } |
There was a problem hiding this comment.
[CONCERN] Registered entries are mutable objects — consumers can corrupt registry state silently
list() returns Array.from(providers.values()) — the actual Map values, not copies. Any consumer that does reg.get('x').label = 'tampered' or mutates an array element from reg.list() will silently corrupt the internal Map without triggering notify().
Fix: freeze entries with Object.freeze(normalised) before storing, or return shallow copies in get() and list().
| onBeforeUnmount(() => { | ||
| unsubscribe() | ||
| }) | ||
|
|
There was a problem hiding this comment.
[CONCERN] getById and resolveWidget are non-reactive — won't recompute on registry changes
getById and resolveWidget are plain arrow functions that close over target. If a template binds them in a reactive context (computed, template expression), they will not re-evaluate when new integrations are registered because Vue's reactivity system has no way to track the Map reads inside target.
Fix: document clearly these helpers are imperative-only, OR implement them to read from snapshot.value so they participate in the reactive graph:
getById: (id) => snapshot.value.find(e => e.id === id) || null,| OCA: { | ||
| OpenRegister: { | ||
| integrations: { | ||
| _queue: [ |
There was a problem hiding this comment.
[CONCERN] installIntegrationRegistry tests share the module-level singleton — tests are order-dependent
Within the installIntegrationRegistry describe block, the integrations singleton is shared across tests. Tests that register entries into it will pollute later tests causing duplicate-id throws in DEV mode.
Fix: call integrations.__resetForTests() in afterEach within the installIntegrationRegistry describe block, or move each test into its own jest.isolateModules scope.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Review
🔴 Blockers (2)
- Module-level singleton leaks across NC apps in same browser tab —
src/integrations/registry.js:235 - Race: snapshot stale if registration fires synchronously before onChange subscribes —
src/composables/useIntegrationRegistry.js:41
🟡 Concerns (4)
- installIntegrationRegistry silently replaces an already-installed real registry —
src/integrations/registry.js:280 - Registered entries are mutable objects — consumers can corrupt registry state silently —
src/integrations/registry.js:155 - getById and resolveWidget are non-reactive — won't recompute on registry changes —
src/composables/useIntegrationRegistry.js:49 - installIntegrationRegistry tests share the module-level singleton — tests are order-dependent —
tests/integrations/registry.spec.js:165
🟢 Minor (4)
- onBeforeUnmount outside component context throws in Vue 2.7 — subscription leaks (
src/composables/useIntegrationRegistry.js:21)
IfuseIntegrationRegistry()is ever called outside a component'ssetup()(e.g. in a Pinia store or test without Vue wrapper),onBeforeUnmountwill throw and the subscription will leak.
Fix: guard with getCurrentInstance() before registering the lifecycle hook, or document clearly in the JSDoc that this composable must only be called from setup().
- resolveWidget has no explicit branch for 'app-dashboard' surface — silent fallthrough (
src/integrations/registry.js:195)
VALID_SURFACESincludes'app-dashboard'butresolveWidgetonly has explicit branches foruser-dashboard,detail-page, andsingle-entity.app-dashboardalways falls through to the defaultwidget. Consider adding a comment noting thatapp-dashboardintentionally has no dedicated override field. - VALID_SURFACES exported twice — redundant trailing export (
src/integrations/registry.js:304)
VALID_SURFACESis declared withexport constat the top level and also listed in a finalexport { VALID_SURFACES }at the bottom of the file. The trailing export is redundant. Remove it. - register() return value inconsistency: throws in DEV on duplicate, returns null in prod (
src/integrations/registry.js:111)
Callers that doconst entry = registry.register(...)and useentrywithout null-checking will work in DEV (throws on collision) but silently fail in prod (returns null). The JSDoc@returntag should document the conditional DEV/prod behaviour. Consider returning the existing entry on prod collision instead of null so callers can always use the return value.
Reviewed by WilcoLouwerse via automated batch review.
|
Skipping CI repair on this PR — the integration-registry surface this PR introduced was already consolidated into |
|
Closing — effectively-merged. Sub-agent verified that beta has independently absorbed the registry source, composable, AND all 13 doc files via the intervening PRs (notably #238 and the AI companion work). Rebasing PR #202 onto current |
Part of the openregister pluggable-integration-registry umbrella. This is the cross-repo PR 6/N landing in
@conduction/nextcloud-vue— implements the JS-side of the registry so consuming apps can register integration tabs and widgets. Follow-up PRs in this repo will add the three missing widgets, refactorCnObjectSidebar/CnDashboardPage/CnDetailPageto consume the registry, and ship the 5 built-in JS registrations.Summary
Adds the runtime that the umbrella's spec calls for at
window.OCA.OpenRegister.integrations. PHP side already shipped (IntegrationProviderinterface + 5 built-in providers + REST routes + OCS capability + admin UI in openregister PRs 1467, 1468, 1469, 1473, 1475). This PR provides the JS twin so PHP + JS stay in lockstep.Implements tasks 6.1–6.4 of the umbrella.
What's in this PR
register/unregister/list/get/has/resolveWidget/onChangeorderascending thenidascending for stable renderingwidgetCompact/widgetExpanded/widgetEntityoverridewidgetper surface; absent overrides fall back to the mainwidgetwithsurfaceprop passed throughtabandwidgetare required at registrationinstallIntegrationRegistry(window)drains a{ _queue: [...] }stub set up by consuming apps that load before OpenRegisteruseIntegrationRegistry()composable — Vue 2.7 reactive snapshot, cleans up subscription on unmountSpec deviation
Spec mentioned
listByGroup()— implemented as an inline filter on the snapshot (integrations.value.filter(p => p.group === 'comms')).resolveWidget(id, surface)was added to encapsulate the AD-19 surface fallback rule so call sites don't have to reimplement it.Files
src/integrations/registry.jssrc/integrations/index.jssrc/composables/useIntegrationRegistry.jssrc/composables/index.jssrc/index.jstests/integrations/registry.spec.jstests/composables/useIntegrationRegistry.spec.jsCLAUDE.mdTest plan
npx jest tests/integrations tests/composables/useIntegrationRegistry.spec.js→ 21 tests, 0 failuresCnAiChatPanel.spec.js+CnMapWidget.spec.js— pre-existing onorigin/beta, unrelated to this changenpm run lint— no new errors or warnings on the files added by this PRCnObjectSidebar/CnDashboardPage/CnDetailPageto consume the registry, and ship the 5 built-in JS registrations