${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",
--
2.51.2