Skip to content

feat(integrations): JS integration registry + useIntegrationRegistry composable#202

Closed
rubenvdlinde wants to merge 1 commit into
betafrom
feature/integration-registry
Closed

feat(integrations): JS integration registry + useIntegrationRegistry composable#202
rubenvdlinde wants to merge 1 commit into
betafrom
feature/integration-registry

Conversation

@rubenvdlinde
Copy link
Copy Markdown
Contributor

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, refactor CnObjectSidebar / CnDashboardPage / CnDetailPage to 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 (IntegrationProvider interface + 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

  • Registryregister/unregister/list/get/has/resolveWidget/onChange
    • Sorted by order ascending then id ascending for 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
    • Parity gate: tab and widget are required at registration
  • Bootstrap-order safetyinstallIntegrationRegistry(window) drains a { _queue: [...] } stub set up by consuming apps that load before OpenRegister
  • useIntegrationRegistry() composable — Vue 2.7 reactive snapshot, cleans up subscription on unmount
  • CLAUDE.md — new "Pluggable Integration Registry" section with full agent guidance: registration shape, surface fallback, collision policy, bootstrap-order safety, in-component example

Spec 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

Path Status
src/integrations/registry.js new
src/integrations/index.js new (barrel)
src/composables/useIntegrationRegistry.js new
src/composables/index.js re-export composable
src/index.js public exports
tests/integrations/registry.spec.js new — 15 tests
tests/composables/useIntegrationRegistry.spec.js new — 4 tests
CLAUDE.md new section under Composables

Test plan

  • npx jest tests/integrations tests/composables/useIntegrationRegistry.spec.js → 21 tests, 0 failures
  • Full Jest suite (972 tests): 4 failing, all in CnAiChatPanel.spec.js + CnMapWidget.spec.js — pre-existing on origin/beta, unrelated to this change
  • npm run lint — no new errors or warnings on the files added by this PR
  • Wire-up smoke test happens in the follow-up PRs that refactor CnObjectSidebar / CnDashboardPage / CnDetailPage to consume the registry, and ship the 5 built-in JS registrations

…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()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)
})
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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()
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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: [
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

🔴 Blockers (2)

🟡 Concerns (4)

🟢 Minor (4)

  • onBeforeUnmount outside component context throws in Vue 2.7 — subscription leaks (src/composables/useIntegrationRegistry.js:21)
    If useIntegrationRegistry() is ever called outside a component's setup() (e.g. in a Pinia store or test without Vue wrapper), onBeforeUnmount will 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_SURFACES includes 'app-dashboard' but resolveWidget only has explicit branches for user-dashboard, detail-page, and single-entity. app-dashboard always falls through to the default widget. Consider adding a comment noting that app-dashboard intentionally has no dedicated override field.
  • VALID_SURFACES exported twice — redundant trailing export (src/integrations/registry.js:304)
    VALID_SURFACES is declared with export const at the top level and also listed in a final export { 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 do const entry = registry.register(...) and use entry without null-checking will work in DEV (throws on collision) but silently fail in prod (returns null). The JSDoc @return tag 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.

@rubenvdlinde
Copy link
Copy Markdown
Contributor Author

Skipping CI repair on this PR — the integration-registry surface this PR introduced was already consolidated into beta via #212 ("chore: consolidate in-flight nc-vue work onto beta — visibleIf context preds + integrations stack + styleguide fixes"). The follow-up integration leaves chain (#231/#232) and 18 leaf integrations are also on beta. This PR is 1 ahead / 44 behind beta with the 1 commit already represented downstream, so it's effectively obsolete. Leaving the CI red intentionally rather than force-rebasing 44 commits over already-merged content. Recommend closing once the maintainer confirms.

@rubenvdlinde
Copy link
Copy Markdown
Contributor Author

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 origin/beta produces a byte-identical diff (git diff origin/beta → 0 lines).

@rubenvdlinde rubenvdlinde deleted the feature/integration-registry branch May 19, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants