diff --git a/src/js/components/plugin-slot.js b/src/js/components/plugin-slot.js index 44ac8df7..fe69c3b7 100644 --- a/src/js/components/plugin-slot.js +++ b/src/js/components/plugin-slot.js @@ -14,9 +14,12 @@ class PluginSlot extends Component { if (!this.pluginService) { throw new Error("pluginService is required"); } - if (!this.interactionHandlers) { - throw new Error("interactionHandlers is required"); - } + // Only surfaces embedding an interactive plugin-rendered list (e.g. + // plugin-posts-feed/plugin-profiles-list) actually need this; slots that + // just render simple, non-interactive content (badges, text) have + // nothing to wire it to, so default it rather than force every call + // site to pass one. + this.interactionHandlers ??= {}; this._pluginRoots = new Map(); this._currentRequest = null; this._subscribe(); @@ -42,7 +45,7 @@ class PluginSlot extends Component { // TODO - automatic? static get observedAttributes() { - return ["name", "context-uri"]; + return ["name", "context-uri", "context-did"]; } attributeChangedCallback(name, oldValue, newValue) { diff --git a/src/js/templates/largePost.template.js b/src/js/templates/largePost.template.js index 84d52b02..b619f97e 100644 --- a/src/js/templates/largePost.template.js +++ b/src/js/templates/largePost.template.js @@ -8,6 +8,7 @@ import { } from "/js/dataHelpers.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import "/js/components/plugin-rich-text.js"; +import "/js/components/plugin-slot.js"; import { postEmbedTemplate } from "/js/templates/postEmbed.template.js"; import { postActionBarTemplate } from "/js/templates/postActionBar.template.js"; import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; @@ -148,6 +149,13 @@ export function largePostTemplate({ ${badgeLabels.length > 0 ? labelBadgesTemplate({ badgeLabels }) : ""} + ${pluginService + ? html`` + : ""}
${mutedWarningTemplate({ post, diff --git a/src/js/templates/postEmbed.template.js b/src/js/templates/postEmbed.template.js index 234ca7f9..26eee66a 100644 --- a/src/js/templates/postEmbed.template.js +++ b/src/js/templates/postEmbed.template.js @@ -11,6 +11,7 @@ import { infoIconTemplate } from "/js/templates/icons/infoIcon.template.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; import { closeWithAnimation } from "/js/dialogHelpers.js"; import "/js/components/plugin-rich-text.js"; +import "/js/components/plugin-slot.js"; import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; import { labelBadgesTemplate } from "/js/templates/labelBadges.template.js"; import { linkToPost, linkToFeed } from "/js/navigation.js"; @@ -223,6 +224,13 @@ export function quotedPostTemplate({ ${quotedPost.badgeLabels ? labelBadgesTemplate({ badgeLabels: quotedPost.badgeLabels }) : ""} + ${pluginService + ? html`` + : ""}
${postText.length > 0 ? html`
diff --git a/src/js/templates/profileCard.template.js b/src/js/templates/profileCard.template.js index aa202e30..ec14b14c 100644 --- a/src/js/templates/profileCard.template.js +++ b/src/js/templates/profileCard.template.js @@ -24,6 +24,7 @@ import "/js/components/detected-rich-text.js"; import { verificationBadgeTemplate } from "/js/templates/verificationBadge.template.js"; import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBadge.template.js"; import { labelBadgesTemplate } from "/js/templates/labelBadges.template.js"; +import "/js/components/plugin-slot.js"; import "/js/components/context-menu.js"; import "/js/components/context-menu-item.js"; import "/js/components/context-menu-item-group.js"; @@ -57,6 +58,7 @@ function profileDescriptionTemplate({ profile, identityResolver, labelerInfo, + pluginService, }) { if (isBlocking) { return html`
@@ -90,6 +92,13 @@ function profileDescriptionTemplate({ ${!isCurrentUser && profile.badgeLabels?.length ? labelBadgesTemplate({ badgeLabels: profile.badgeLabels }) : ""} + ${pluginService + ? html`` + : ""} `; } @@ -445,6 +454,7 @@ export function profileCardTemplate({ labelerInfo, profile, identityResolver, + pluginService, })}
`; } diff --git a/src/js/templates/smallPost.template.js b/src/js/templates/smallPost.template.js index 73d25062..ec69b322 100644 --- a/src/js/templates/smallPost.template.js +++ b/src/js/templates/smallPost.template.js @@ -10,6 +10,7 @@ import { noop } from "/js/utils.js"; import { linkToPost, linkToProfile } from "/js/navigation.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import "/js/components/plugin-rich-text.js"; +import "/js/components/plugin-slot.js"; import { postEmbedTemplate } from "/js/templates/postEmbed.template.js"; import { postActionBarTemplate } from "/js/templates/postActionBar.template.js"; import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; @@ -144,6 +145,13 @@ export function smallPostTemplate({ ${post.badgeLabels ? labelBadgesTemplate({ badgeLabels: post.badgeLabels }) : ""} + ${pluginService + ? html`` + : ""} ${showReplyToLabel ? html`
${cornerDownRightIconTemplate()} Replied to diff --git a/src/js/views/chatDetail.view.js b/src/js/views/chatDetail.view.js index 924bdebd..7af82ba2 100644 --- a/src/js/views/chatDetail.view.js +++ b/src/js/views/chatDetail.view.js @@ -23,6 +23,7 @@ import { avatarGroupTemplate } from "/js/templates/avatarGroup.template.js"; import { verificationBadgeTemplate } from "/js/templates/verificationBadge.template.js"; import { automatedAccountBadgeTemplate } from "/js/templates/automatedAccountBadge.template.js"; import { labelBadgesTemplate } from "/js/templates/labelBadges.template.js"; +import "/js/components/plugin-slot.js"; import { postEmbedTemplate, recordEmbedTemplate, @@ -1136,6 +1137,13 @@ class ChatDetailView extends View { ${hasBadgeLabels ? labelBadgesTemplate({ badgeLabels: profile.badgeLabels }) : ""} + ${pluginService + ? html`` + : ""} { assert.deepEqual(captured, ["at://one", "at://two"]); assert.deepEqual(slot.children[0].textContent, "at://two"); }); + + it("re-renders when context-did changes on an existing element", async () => { + const captured = []; + const pluginService = makePluginService({ + entries: { + "author-badges": [ + { + pluginId: "alpha", + invoke: async (context) => { + captured.push(context.did); + return { tag: "div", text: context.did }; + }, + }, + ], + }, + }); + const slot = makeSlot({ + pluginService, + name: "author-badges", + context: { did: "did:plc:one" }, + }); + document.body.appendChild(slot); + await flushMicrotasks(); + assert.deepEqual(captured, ["did:plc:one"]); + + slot.setAttribute("context-did", "did:plc:two"); + await flushMicrotasks(); + assert.deepEqual(captured, ["did:plc:one", "did:plc:two"]); + assert.deepEqual(slot.children[0].textContent, "did:plc:two"); + }); + + it("supports multiple simultaneous instances of the same slot name, each with its own context", async () => { + const captured = []; + const pluginService = makePluginService({ + entries: { + "author-badges": [ + { + pluginId: "alpha", + invoke: async (context) => { + captured.push(context.did); + return { tag: "div", text: context.did }; + }, + }, + ], + }, + }); + const slotOne = makeSlot({ + pluginService, + name: "author-badges", + context: { did: "did:plc:one" }, + }); + const slotTwo = makeSlot({ + pluginService, + name: "author-badges", + context: { did: "did:plc:two" }, + }); + document.body.appendChild(slotOne); + document.body.appendChild(slotTwo); + await flushMicrotasks(); + assert.deepEqual( + new Set(captured), + new Set(["did:plc:one", "did:plc:two"]), + ); + assert.deepEqual(slotOne.children[0].textContent, "did:plc:one"); + assert.deepEqual(slotTwo.children[0].textContent, "did:plc:two"); + }); }); describe("PluginSlot - interactionHandlers", () => { - it("throws when interactionHandlers is not set", () => { + it("defaults to an empty object when not set, rather than throwing", () => { const element = document.createElement("plugin-slot"); element.pluginService = makePluginService(); element.setAttribute("name", "x"); + element.connectedCallback(); + assert.deepEqual(element.interactionHandlers, {}); + }); + + it("throws when pluginService is not set", () => { + const element = document.createElement("plugin-slot"); + element.setAttribute("name", "x"); let caught = null; try { element.connectedCallback(); @@ -270,7 +343,7 @@ describe("plugin-slot", () => { caught = error; } assert(caught instanceof Error); - assert.deepEqual(caught.message, "interactionHandlers is required"); + assert.deepEqual(caught.message, "pluginService is required"); }); }); diff --git a/tests/unit/specs/components/post-composer.test.js b/tests/unit/specs/components/post-composer.test.js index 1ec446fb..c82841af 100644 --- a/tests/unit/specs/components/post-composer.test.js +++ b/tests/unit/specs/components/post-composer.test.js @@ -43,6 +43,9 @@ describe("post-composer", () => { $richTextTransformsVersion: { get: () => 0 }, transformRichTextTokens: async () => null, renderRichTextNodeToken: () => null, + getClaimedFacetTypes: () => new Set(), + $slots: { get: () => null }, + getSlotEntries: () => [], }; return element; } diff --git a/tests/unit/specs/templates/largePost.template.test.js b/tests/unit/specs/templates/largePost.template.test.js index 72fcc1ea..3653eb52 100644 --- a/tests/unit/specs/templates/largePost.template.test.js +++ b/tests/unit/specs/templates/largePost.template.test.js @@ -24,6 +24,8 @@ const pluginService = { transformRichTextTokens: async () => null, renderRichTextNodeToken: () => null, getClaimedFacetTypes: () => new Set(), + $slots: { get: () => null }, + getSlotEntries: () => [], }; const baseProps = { diff --git a/tests/unit/specs/templates/postEmbed.template.test.js b/tests/unit/specs/templates/postEmbed.template.test.js index be40d5a7..726fa634 100644 --- a/tests/unit/specs/templates/postEmbed.template.test.js +++ b/tests/unit/specs/templates/postEmbed.template.test.js @@ -12,6 +12,8 @@ const pluginService = { transformRichTextTokens: async () => null, renderRichTextNodeToken: () => null, getClaimedFacetTypes: () => new Set(), + $slots: { get: () => null }, + getSlotEntries: () => [], }; describe("postEmbedTemplate - images", () => { diff --git a/tests/unit/specs/templates/smallPost.template.test.js b/tests/unit/specs/templates/smallPost.template.test.js index a929cbba..aa698227 100644 --- a/tests/unit/specs/templates/smallPost.template.test.js +++ b/tests/unit/specs/templates/smallPost.template.test.js @@ -24,6 +24,8 @@ const pluginService = { transformRichTextTokens: async () => null, renderRichTextNodeToken: () => null, getClaimedFacetTypes: () => new Set(), + $slots: { get: () => null }, + getSlotEntries: () => [], }; const baseProps = { -- 2.51.2 From 34787502e50446fb0ddc417f5fee2d5895b373c9 Mon Sep 17 00:00:00 2001 From: Nameless 7778777 <7778777@7778777.online> Date: Mon, 27 Jul 2026 12:44:55 +0200 Subject: [PATCH 2/5] Let a plugin force a mounted slot to redraw via refreshSlot registerSlot callbacks can resolve asynchronously (e.g. a plugin doing a lookup on a local-cache miss), but nothing invalidates a slot once it's already rendered. refreshSlot(name) lets a plugin explicitly trigger a redraw of every plugin registered against a given slot name once such data lands - the natural companion to author-badges, the first slot likely to have per-item async content. Co-Authored-By: Claude Sonnet 5 --- impro-plugin/main.js | 8 +++ plugins.md | 7 +++ src/js/plugins/pluginService.js | 9 +++ .../unit/specs/plugins/pluginService.test.js | 56 +++++++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 66f9214f..3468291a 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -404,6 +404,14 @@ export class Plugin { }); } + // Forces every mounted to re-invoke its registered + // plugins' callbacks (including other plugins' — the slot has no concept + // of per-plugin refresh). Useful when a slot's content depends on data + // that resolves asynchronously after the initial render. + refreshSlot(name) { + return hostCall("refreshSlot", { name }); + } + onload() {} onunload() {} diff --git a/plugins.md b/plugins.md index 41c2e410..498c7abe 100644 --- a/plugins.md +++ b/plugins.md @@ -40,6 +40,13 @@ Plugins are currently in **beta** as the API surface is being expanded. However, - Make whitelisted network requests (requires permissions) - Read appview data with the current user as the viewer (profiles, posts, etc.) - Mute, block, or send feed feedback ("show more/less like this") on the user's behalf (requires permissions) +- Render custom inline content next to a user's name wherever moderation label badges + would otherwise show — profile header, post byline (feed/thread/quoted), and the DM info + panel — via `registerSlot("author-badges", callback)`, invoked once per rendered user with + `context.did` set to that user's DID. Any registered slot (including this one) can be + forced to redraw via `refreshSlot(name)`, useful when a slot's content depends on data + that resolves asynchronously after the initial render (e.g. a lookup that misses a local + cache and wants to redraw once it resolves). ### Plugins CANNOT: diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 71204d97..cf004fb3 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -359,6 +359,15 @@ export class PluginService extends ReactiveStore { }, ); + // Forces every mounted to re-invoke its + // registered plugins, for slots whose content depends on data that + // resolved asynchronously after the initial render (e.g. a plugin that + // fetches something on a cache miss and wants to redraw once it lands). + this.pluginBridge.addHostMethod("refreshSlot", (plugin, { name }) => { + const current = this.$slots.get(name); + if (current) this.$slots.set(name, [...current]); + }); + this.pluginBridge.addHostMethod( "refreshFeedFilters", (plugin, feedURI = null) => { diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js index ee60ded9..032a2ebd 100644 --- a/tests/unit/specs/plugins/pluginService.test.js +++ b/tests/unit/specs/plugins/pluginService.test.js @@ -1432,6 +1432,62 @@ describe("slot registry", () => { }); }); +describe("refreshSlot host method", () => { + function makeServiceWithRealBridge() { + const { provider } = makeProvider(); + return new PluginService( + provider, + null, + emptyDataLayer(), + new HiddenFeedItemsStore(), + ); + } + + function register(service, plugin, message) { + const handler = service.pluginBridge._registrationTargets.get("slot"); + return handler(plugin, message); + } + + function getHandler(service, name) { + return service.pluginBridge._hostCallHandlers.get(name); + } + + it("triggers effects watching $slots for that name without changing its entries", () => { + const service = makeServiceWithRealBridge(); + register( + service, + { pluginId: "alpha", call: () => {} }, + { + target: "slot", + name: "author-badges", + handlerId: 1, + }, + ); + const before = service.$slots.get("author-badges"); + getHandler(service, "refreshSlot")( + { pluginId: "alpha" }, + { name: "author-badges" }, + ); + const after = service.$slots.get("author-badges"); + assert.notEqual(before, after); + assert.deepEqual( + after.map((entry) => entry.pluginId), + before.map((entry) => entry.pluginId), + ); + }); + + it("is a no-op for a slot name nobody has registered", () => { + const service = makeServiceWithRealBridge(); + assert.doesNotThrow(() => + getHandler(service, "refreshSlot")( + { pluginId: "alpha" }, + { name: "nope" }, + ), + ); + assert.deepEqual(service.$slots.get("nope"), null); + }); +}); + describe("app.data host methods", () => { function makeStubComputedMap(lookup) { const calls = []; -- 2.51.2 From ff31e8330f53c867c1c3706c5baf022f094eebfc Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 27 Jul 2026 23:17:31 -0500 Subject: [PATCH 3/5] Remove interactionhandlers from plugin-slot --- src/js/components/plugin-slot.js | 6 ------ src/js/views/postThread.view.js | 6 ------ .../unit/specs/components/plugin-slot.test.js | 18 ++---------------- 3 files changed, 2 insertions(+), 28 deletions(-) diff --git a/src/js/components/plugin-slot.js b/src/js/components/plugin-slot.js index fe69c3b7..ee356d32 100644 --- a/src/js/components/plugin-slot.js +++ b/src/js/components/plugin-slot.js @@ -14,12 +14,6 @@ class PluginSlot extends Component { if (!this.pluginService) { throw new Error("pluginService is required"); } - // Only surfaces embedding an interactive plugin-rendered list (e.g. - // plugin-posts-feed/plugin-profiles-list) actually need this; slots that - // just render simple, non-interactive content (badges, text) have - // nothing to wire it to, so default it rather than force every call - // site to pass one. - this.interactionHandlers ??= {}; this._pluginRoots = new Map(); this._currentRequest = null; this._subscribe(); diff --git a/src/js/views/postThread.view.js b/src/js/views/postThread.view.js index 94e54948..5e91b07a 100644 --- a/src/js/views/postThread.view.js +++ b/src/js/views/postThread.view.js @@ -256,13 +256,11 @@ class PostThreadView extends View { name="post-thread-view:replies-empty" context-uri=${postUri} .pluginService=${pluginService} - .interactionHandlers=${interactionHandlers} >` : html`
${replyChains.map((replyChain, i) => @@ -296,7 +294,6 @@ class PostThreadView extends View { name="post-thread-view:after-replies" context-uri=${postUri} .pluginService=${pluginService} - .interactionHandlers=${interactionHandlers} >
@@ -380,7 +377,6 @@ class PostThreadView extends View { name="post-thread-view:top" context-uri=${postUri} .pluginService=${pluginService} - .interactionHandlers=${interactionHandlers} >
${parents.map((parent, i) => { const parentPost = parent.post ? parent.post : parent; @@ -422,7 +418,6 @@ class PostThreadView extends View { name="post-thread-view:before-main" context-uri=${postUri} .pluginService=${pluginService} - .interactionHandlers=${interactionHandlers} >
${hiddenUnauthenticated @@ -452,7 +447,6 @@ class PostThreadView extends View { name="post-thread-view:after-main" context-uri=${postUri} .pluginService=${pluginService} - .interactionHandlers=${interactionHandlers} > ${!postThread.__isEmbeddedPrefill && isAuthenticated && diff --git a/tests/unit/specs/components/plugin-slot.test.js b/tests/unit/specs/components/plugin-slot.test.js index 9886d948..cbbd4182 100644 --- a/tests/unit/specs/components/plugin-slot.test.js +++ b/tests/unit/specs/components/plugin-slot.test.js @@ -52,15 +52,9 @@ describe("plugin-slot", () => { }; } - function makeSlot({ - pluginService, - name, - context = {}, - interactionHandlers, - }) { + function makeSlot({ pluginService, name, context = {} }) { const element = document.createElement("plugin-slot"); element.pluginService = pluginService; - element.interactionHandlers = interactionHandlers ?? {}; element.setAttribute("name", name); for (const [key, value] of Object.entries(context)) { element.setAttribute(`context-${key}`, value); @@ -324,15 +318,7 @@ describe("plugin-slot", () => { }); }); - describe("PluginSlot - interactionHandlers", () => { - it("defaults to an empty object when not set, rather than throwing", () => { - const element = document.createElement("plugin-slot"); - element.pluginService = makePluginService(); - element.setAttribute("name", "x"); - element.connectedCallback(); - assert.deepEqual(element.interactionHandlers, {}); - }); - + describe("PluginSlot - initialization", () => { it("throws when pluginService is not set", () => { const element = document.createElement("plugin-slot"); element.setAttribute("name", "x"); -- 2.51.2 From db3174c2a417d4467bfb2a66c12637b2e0596559 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 27 Jul 2026 23:55:25 -0500 Subject: [PATCH 4/5] Scope refreshes by plugin --- impro-plugin/main.js | 7 +- src/js/components/plugin-slot.js | 24 ++++- src/js/plugins/pluginService.js | 26 +++-- .../unit/specs/components/plugin-slot.test.js | 71 ++++++++++++ .../unit/specs/plugins/pluginService.test.js | 102 +++++++++++++++++- 5 files changed, 213 insertions(+), 17 deletions(-) diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 3468291a..d490dd47 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -404,10 +404,9 @@ export class Plugin { }); } - // Forces every mounted to re-invoke its registered - // plugins' callbacks (including other plugins' — the slot has no concept - // of per-plugin refresh). Useful when a slot's content depends on data - // that resolves asynchronously after the initial render. + // Makes every mounted re-invoke this plugin's + // registered callbacks for that slot. Useful when a slot's content depends + // on data that resolves asynchronously after the initial render. refreshSlot(name) { return hostCall("refreshSlot", { name }); } diff --git a/src/js/components/plugin-slot.js b/src/js/components/plugin-slot.js index ee356d32..1b5f30d7 100644 --- a/src/js/components/plugin-slot.js +++ b/src/js/components/plugin-slot.js @@ -65,6 +65,7 @@ class PluginSlot extends Component { const slotName = this.getAttribute("name"); if (!slotName) return; const context = this._getContext(); + const contextKey = JSON.stringify(context); const entries = this.pluginService.getSlotEntries(slotName); const requestToken = Symbol(); @@ -81,8 +82,18 @@ class PluginSlot extends Component { return; } + // Only re-invoke a plugin when its entry version changed + // or the context changed - otherwise use cached response const results = await Promise.all( entries.map(async (entry) => { + const cached = this._pluginRoots.get(entry.pluginId); + if ( + cached && + cached.version === entry.version && + cached.contextKey === contextKey + ) { + return { entry, node: null, reuseCached: true }; + } try { const node = await entry.invoke(context); return { entry, node }; @@ -99,9 +110,12 @@ class PluginSlot extends Component { if (this._currentRequest !== requestToken) return; const nextChildren = []; - for (const { entry, node } of results) { - if (!node) continue; + for (const { entry, node, reuseCached } of results) { let state = this._pluginRoots.get(entry.pluginId); + if (reuseCached) { + if (state.element) nextChildren.push(state.element); + continue; + } if (!state) { const renderer = this.pluginService.getRenderer(entry.pluginId); state = { @@ -109,8 +123,10 @@ class PluginSlot extends Component { }; this._pluginRoots.set(entry.pluginId, state); } - const element = state.root.render(node); - nextChildren.push(element); + state.version = entry.version; + state.contextKey = contextKey; + state.element = node ? state.root.render(node) : null; + if (state.element) nextChildren.push(state.element); } this.replaceChildren(...nextChildren); } diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index cf004fb3..9d82b312 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -170,6 +170,7 @@ export class PluginService extends ReactiveStore { this._richTextElements = new WeakMap(); this.$settingTabs = new SignalMap(); this.$slots = new SignalMap(); + this._slotEntryVersion = 0; this.localPluginsEnabled = isDev(); this.remoteRegistry = new RemotePluginRegistry(PLUGIN_REGISTRY_URL); this.localRegistry = this.localPluginsEnabled @@ -302,16 +303,23 @@ export class PluginService extends ReactiveStore { }, ); this.pluginBridge.addRegistrationTarget("slot", (plugin, message) => { + const current = this.$slots.get(message.name) ?? []; + if (current.some((other) => other.pluginId === plugin.pluginId)) { + console.warn( + `"${plugin.pluginId}" is already registered for slot "${message.name}"; ignoring duplicate registration`, + ); + return null; + } const entry = { pluginId: plugin.pluginId, + version: ++this._slotEntryVersion, invoke: (context) => plugin.call(message.handlerId, context), }; - const current = this.$slots.get(message.name) ?? []; this.$slots.set(message.name, [...current, entry]); return () => { const list = this.$slots.get(message.name); if (!list) return; - const next = list.filter((other) => other !== entry); + const next = list.filter((other) => other.pluginId !== plugin.pluginId); if (next.length === 0) { this.$slots.delete(message.name); } else { @@ -359,13 +367,17 @@ export class PluginService extends ReactiveStore { }, ); - // Forces every mounted to re-invoke its - // registered plugins, for slots whose content depends on data that - // resolved asynchronously after the initial render (e.g. a plugin that - // fetches something on a cache miss and wants to redraw once it lands). this.pluginBridge.addHostMethod("refreshSlot", (plugin, { name }) => { const current = this.$slots.get(name); - if (current) this.$slots.set(name, [...current]); + if (!current?.some((entry) => entry.pluginId === plugin.pluginId)) { + return; + } + const next = current.map((entry) => + entry.pluginId === plugin.pluginId + ? { ...entry, version: ++this._slotEntryVersion } + : entry, + ); + this.$slots.set(name, next); }); this.pluginBridge.addHostMethod( diff --git a/tests/unit/specs/components/plugin-slot.test.js b/tests/unit/specs/components/plugin-slot.test.js index cbbd4182..ee6f89a2 100644 --- a/tests/unit/specs/components/plugin-slot.test.js +++ b/tests/unit/specs/components/plugin-slot.test.js @@ -318,6 +318,77 @@ describe("plugin-slot", () => { }); }); + describe("PluginSlot - per-plugin refresh", () => { + function makeVersionedEntries() { + const invokeCounts = { alpha: 0, beta: 0 }; + const entries = [ + { + pluginId: "alpha", + version: 0, + invoke: async () => { + invokeCounts.alpha += 1; + return { tag: "div", text: `A${invokeCounts.alpha}` }; + }, + }, + { + pluginId: "beta", + version: 0, + invoke: async () => { + invokeCounts.beta += 1; + return { tag: "div", text: `B${invokeCounts.beta}` }; + }, + }, + ]; + return { entries, invokeCounts }; + } + + it("re-invokes only the plugin whose entry version was bumped", async () => { + const { entries, invokeCounts } = makeVersionedEntries(); + const pluginService = makePluginService({ entries: { x: entries } }); + const slot = makeSlot({ pluginService, name: "x" }); + document.body.appendChild(slot); + await flushMicrotasks(); + assert.deepEqual(invokeCounts, { alpha: 1, beta: 1 }); + const betaElement = slot.children[1]; + + entries[0].version += 1; + pluginService.setSlotEntries("x", entries); + await flushMicrotasks(); + assert.deepEqual(invokeCounts, { alpha: 2, beta: 1 }); + assert.deepEqual(slot.children[0].textContent, "A2"); + assert.equal(slot.children[1], betaElement); + }); + + it("does not re-invoke any plugin when the list is re-set unchanged", async () => { + const { entries, invokeCounts } = makeVersionedEntries(); + const pluginService = makePluginService({ entries: { x: entries } }); + const slot = makeSlot({ pluginService, name: "x" }); + document.body.appendChild(slot); + await flushMicrotasks(); + + pluginService.setSlotEntries("x", entries); + await flushMicrotasks(); + assert.deepEqual(invokeCounts, { alpha: 1, beta: 1 }); + assert.deepEqual(slot.children.length, 2); + }); + + it("still re-invokes every plugin when the context changes", async () => { + const { entries, invokeCounts } = makeVersionedEntries(); + const pluginService = makePluginService({ entries: { x: entries } }); + const slot = makeSlot({ + pluginService, + name: "x", + context: { uri: "at://one" }, + }); + document.body.appendChild(slot); + await flushMicrotasks(); + + slot.setAttribute("context-uri", "at://two"); + await flushMicrotasks(); + assert.deepEqual(invokeCounts, { alpha: 2, beta: 2 }); + }); + }); + describe("PluginSlot - initialization", () => { it("throws when pluginService is not set", () => { const element = document.createElement("plugin-slot"); diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js index 032a2ebd..91c8c65f 100644 --- a/tests/unit/specs/plugins/pluginService.test.js +++ b/tests/unit/specs/plugins/pluginService.test.js @@ -1398,6 +1398,50 @@ describe("slot registry", () => { assert.deepEqual(calls, [{ handlerId: 7, args: [{ uri: "at://test" }] }]); }); + it("never reuses an entry version for a later registration", () => { + const service = makeServiceWithRealBridge(); + const dispose = register(service, makePlugin("alpha"), { + target: "slot", + name: "x", + handlerId: 1, + }); + const firstVersion = service.getSlotEntries("x")[0].version; + dispose(); + register(service, makePlugin("alpha"), { + target: "slot", + name: "x", + handlerId: 2, + }); + assert.notEqual(service.getSlotEntries("x")[0].version, firstVersion); + }); + + it("warns and skips when a plugin registers the same slot twice", () => { + const service = makeServiceWithRealBridge(); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + register(service, makePlugin("alpha"), { + target: "slot", + name: "x", + handlerId: 1, + }); + const dispose = register(service, makePlugin("alpha"), { + target: "slot", + name: "x", + handlerId: 2, + }); + assert.deepEqual(dispose, null); + assert.deepEqual(warnings.length, 1); + assert(warnings[0].includes("alpha")); + const entries = service.getSlotEntries("x"); + assert.deepEqual(entries.length, 1); + assert.deepEqual(entries[0].pluginId, "alpha"); + } finally { + console.warn = originalWarn; + } + }); + it("dispose removes the entry and prunes the slot when empty", () => { const service = makeServiceWithRealBridge(); const dispose = register(service, makePlugin("alpha"), { @@ -1452,7 +1496,7 @@ describe("refreshSlot host method", () => { return service.pluginBridge._hostCallHandlers.get(name); } - it("triggers effects watching $slots for that name without changing its entries", () => { + it("bumps only the calling plugin's entry versions and re-sets the list", () => { const service = makeServiceWithRealBridge(); register( service, @@ -1463,7 +1507,19 @@ describe("refreshSlot host method", () => { handlerId: 1, }, ); + register( + service, + { pluginId: "beta", call: () => {} }, + { + target: "slot", + name: "author-badges", + handlerId: 1, + }, + ); const before = service.$slots.get("author-badges"); + const [alphaVersionBefore, betaVersionBefore] = before.map( + (entry) => entry.version, + ); getHandler(service, "refreshSlot")( { pluginId: "alpha" }, { name: "author-badges" }, @@ -1472,8 +1528,29 @@ describe("refreshSlot host method", () => { assert.notEqual(before, after); assert.deepEqual( after.map((entry) => entry.pluginId), - before.map((entry) => entry.pluginId), + ["alpha", "beta"], ); + assert.notEqual(after[0].version, alphaVersionBefore); + assert.equal(after[1].version, betaVersionBefore); + }); + + it("dispose still removes the entry after a refresh replaced it", () => { + const service = makeServiceWithRealBridge(); + const dispose = register( + service, + { pluginId: "alpha", call: () => {} }, + { + target: "slot", + name: "author-badges", + handlerId: 1, + }, + ); + getHandler(service, "refreshSlot")( + { pluginId: "alpha" }, + { name: "author-badges" }, + ); + dispose(); + assert.deepEqual(service.$slots.get("author-badges"), null); }); it("is a no-op for a slot name nobody has registered", () => { @@ -1486,6 +1563,27 @@ describe("refreshSlot host method", () => { ); assert.deepEqual(service.$slots.get("nope"), null); }); + + it("is a no-op when the calling plugin has no entry in the slot", () => { + const service = makeServiceWithRealBridge(); + register( + service, + { pluginId: "alpha", call: () => {} }, + { + target: "slot", + name: "author-badges", + handlerId: 1, + }, + ); + const before = service.$slots.get("author-badges"); + const versionBefore = before[0].version; + getHandler(service, "refreshSlot")( + { pluginId: "beta" }, + { name: "author-badges" }, + ); + assert.equal(service.$slots.get("author-badges"), before); + assert.equal(before[0].version, versionBefore); + }); }); describe("app.data host methods", () => { -- 2.51.2 From 33365bb0bd4642a742acc93844169a742d8bd179 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 27 Jul 2026 23:59:37 -0500 Subject: [PATCH 5/5] Update versions --- impro-plugin/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/impro-plugin/package.json b/impro-plugin/package.json index 18550472..bc597e00 100644 --- a/impro-plugin/package.json +++ b/impro-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@impro.social/impro-plugin", - "version": "0.0.16", + "version": "0.0.17", "type": "module", "main": "main.js", "license": "0BSD", diff --git a/package.json b/package.json index 4e578e53..d3f103f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.97", + "version": "0.18.98", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",