From b00b0cfb94fa4d5f11988ff8e82d7588dbe5c06d Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Sun, 2 Aug 2026 00:38:55 -0500 Subject: [PATCH] Add slot caching --- impro-plugin/main.js | 63 +- impro-plugin/package.json | 2 +- package.json | 2 +- src/js/components/plugin-slot.js | 125 +-- src/js/plugins/pluginRichTextDispatcher.js | 168 ++++ src/js/plugins/pluginService.js | 242 +----- src/js/plugins/pluginSlotDispatcher.js | 344 +++++++++ src/js/router.js | 17 +- src/js/utils.js | 134 ++++ .../specs/concerns/pluginSlotCache.test.js | 136 ++++ tests/e2e/testPlugin.js | 50 ++ .../components/plugin-profiles-list.test.js | 11 +- .../specs/components/plugin-rich-text.test.js | 7 +- .../unit/specs/components/plugin-slot.test.js | 251 +++++- .../specs/components/post-composer.test.js | 10 +- tests/unit/specs/mainLayout.test.js | 9 +- .../plugins/pluginRichTextDispatcher.test.js | 411 ++++++++++ .../unit/specs/plugins/pluginService.test.js | 722 +++--------------- .../plugins/pluginSlotDispatcher.test.js | 650 ++++++++++++++++ tests/unit/specs/router.test.js | 43 ++ .../templates/largePost.template.test.js | 11 +- .../templates/postEmbed.template.test.js | 10 +- .../templates/smallPost.template.test.js | 11 +- tests/unit/specs/utils.test.js | 262 +++++++ tests/unit/testHelpers.js | 19 + 25 files changed, 2731 insertions(+), 979 deletions(-) create mode 100644 src/js/plugins/pluginRichTextDispatcher.js create mode 100644 src/js/plugins/pluginSlotDispatcher.js create mode 100644 tests/e2e/specs/concerns/pluginSlotCache.test.js create mode 100644 tests/unit/specs/plugins/pluginRichTextDispatcher.test.js create mode 100644 tests/unit/specs/plugins/pluginSlotDispatcher.test.js diff --git a/impro-plugin/main.js b/impro-plugin/main.js index 66a6b216..dc5ec8f4 100644 --- a/impro-plugin/main.js +++ b/impro-plugin/main.js @@ -395,32 +395,53 @@ export class Plugin { }); } - registerSlot(name, callback = () => null) { + // options.cacheKey: array of context fields. If provided, the host + // will treat the slot content as a pure function of these fields + // - omitting other fields in the callback and caching return values + // until they're invalidated by refreshSlot(). An empty array declares + // that the content depends on no context at all, so one cached result + // serves every instance. + // + // The host batches all pending contexts of a render into one call. + registerSlot(name, callback = () => null, options = {}) { const handlerId = uuid.create(); - callHandlers.set(handlerId, async (context) => { - const result = await callback(context); - if (result == null) return null; - if (!(result instanceof VirtualEl)) { - const description = result?.constructor?.name ?? typeof result; - throw new Error( - `Slot "${name}" must return a VirtualEl (or null), got ${description}`, - ); + callHandlers.set(handlerId, async (batch) => { + const results = []; + for (const context of batch) { + try { + results.push({ + value: await getSlotContent(name, callback, context), + }); + } catch (error) { + results.push({ error: error?.message ?? String(error) }); + } } - return result._serialize(); + return results; }); + const cacheKey = Array.isArray(options.cacheKey) + ? options.cacheKey.filter((field) => typeof field === "string") + : null; self.postMessage({ type: "register", target: "slot", name, handlerId, + cacheKey, + batch: true, }); } - // 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 }); + // Makes mounted instances re-invoke this plugin's + // registered callback for that slot, and drops any cached results. Useful + // when a slot's content depends on plugin state that changed after render. + // + // options.keys: array of matcher objects to be OR'd together, + // e.g. [{ did: "..." }] - any matching slots will be invalidated / refreshed. + // Omit to refresh every instance. A slot registered with a cacheKey can only + // be matched on those declared fields, since its output depends on nothing + // else. + refreshSlot(name, options = {}) { + return hostCall("refreshSlot", { name, keys: options.keys ?? null }); } onload() {} @@ -446,6 +467,18 @@ export class Plugin { } } +async function getSlotContent(name, callback, context) { + const result = await callback(context); + if (result == null) return null; + if (!(result instanceof VirtualEl)) { + const description = result?.constructor?.name ?? typeof result; + throw new Error( + `Slot "${name}" must return a VirtualEl or null, got ${description}`, + ); + } + return result._serialize(); +} + function serializeTransformTokens(tokens) { if (!Array.isArray(tokens)) return tokens; return tokens.map((token) => { diff --git a/impro-plugin/package.json b/impro-plugin/package.json index 1e0bb7c7..d8f5423d 100644 --- a/impro-plugin/package.json +++ b/impro-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@impro.social/impro-plugin", - "version": "0.0.18", + "version": "0.0.19", "type": "module", "main": "main.js", "license": "0BSD", diff --git a/package.json b/package.json index ca7e586b..52dfc5e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.142", + "version": "0.18.143", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/components/plugin-slot.js b/src/js/components/plugin-slot.js index 59a73531..abfa45c4 100644 --- a/src/js/components/plugin-slot.js +++ b/src/js/components/plugin-slot.js @@ -1,5 +1,6 @@ import { Component } from "/js/components/component.js"; import { effect } from "/js/signals.js"; +import { isPromise } from "/js/utils.js"; const CONTEXT_PREFIX = "context-"; @@ -14,7 +15,8 @@ class PluginSlot extends Component { if (!this.pluginService) { throw new Error("pluginService is required"); } - this._pluginRoots = new Map(); + // pluginId -> { root, element, version, contextKey } + this._pluginRenderState = new Map(); this._currentRequest = null; } this._subscribe(); @@ -35,7 +37,7 @@ class PluginSlot extends Component { this._disposeEffect?.(); this._disposeEffect = null; this._currentRequest = null; - this._pluginRoots.clear(); + this._pluginRenderState.clear(); } static get observedAttributes() { @@ -61,72 +63,101 @@ class PluginSlot extends Component { return context; } - async _reconcile() { + _reconcile() { const slotName = this.getAttribute("name"); if (!slotName) return; const context = this._getContext(); - const contextKey = JSON.stringify(context); - const entries = this.pluginService.getSlotEntries(slotName); + const registrations = this.pluginService.getSlotRegistrations(slotName); const requestToken = Symbol(); this._currentRequest = requestToken; - // Drop cached roots for plugins no longer registered for this slot. - const currentIds = new Set(entries.map((entry) => entry.pluginId)); - for (const pluginId of [...this._pluginRoots.keys()]) { - if (!currentIds.has(pluginId)) this._pluginRoots.delete(pluginId); + // Drop render state for plugins no longer registered for this slot. + const currentIds = new Set( + registrations.map((registration) => registration.pluginId), + ); + for (const pluginId of [...this._pluginRenderState.keys()]) { + if (!currentIds.has(pluginId)) this._pluginRenderState.delete(pluginId); } - if (entries.length === 0) { + if (registrations.length === 0) { this.replaceChildren(); 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 }; - } catch (error) { - console.error( - `Plugin "${entry.pluginId}" slot "${slotName}" failed:`, - error, - ); - return { entry, node: null }; - } - }), - ); + // Contribution: `{ registration, contextKey, node | unchanged }` + // If we need to make request for content, return a Promise + const awaitableContributions = registrations.map((registration) => { + const version = registration.versionFor(context); + // Each registration is only sensitive to part of the context, so a + // change outside that part leaves its rendered content untouched + const contextKey = registration.contextKeyFor(context); + const renderState = this._pluginRenderState.get(registration.pluginId); + if ( + renderState && + renderState.version === version && + renderState.contextKey === contextKey + ) { + return { registration, version, contextKey, unchanged: true }; + } + const onError = (error) => { + console.error( + `Plugin "${registration.pluginId}" slot "${slotName}" failed:`, + error, + ); + return { registration, version, contextKey, node: null }; + }; + let content = null; + try { + content = registration.request(context); + } catch (error) { + return onError(error); + } + if (!isPromise(content)) { + return { registration, version, contextKey, node: content }; + } + return content.then( + (node) => ({ registration, version, contextKey, node }), + onError, + ); + }); - if (this._currentRequest !== requestToken) return; + // If no contributions need to be awaited, render synchronously + if (!awaitableContributions.some(isPromise)) { + this._render(awaitableContributions); + return; + } + Promise.all(awaitableContributions).then((contributions) => { + if (this._currentRequest !== requestToken) return; + this._render(contributions); + }); + } + _render(contributions) { const nextChildren = []; - for (const { entry, node, reuseCached } of results) { - let state = this._pluginRoots.get(entry.pluginId); - if (reuseCached) { - if (state.element) nextChildren.push(state.element); + for (const { + registration, + version, + contextKey, + node, + unchanged, + } of contributions) { + let renderState = this._pluginRenderState.get(registration.pluginId); + if (unchanged) { + if (renderState?.element) nextChildren.push(renderState.element); continue; } - if (!state) { - const renderer = this.pluginService.getRenderer(entry.pluginId); - state = { + if (!renderState) { + const renderer = this.pluginService.getRenderer(registration.pluginId); + renderState = { root: renderer.createRoot(), }; - this._pluginRoots.set(entry.pluginId, state); + this._pluginRenderState.set(registration.pluginId, renderState); } - state.version = entry.version; - state.contextKey = contextKey; - state.element = node ? state.root.render(node) : null; - if (state.element) nextChildren.push(state.element); + renderState.version = version; + renderState.contextKey = contextKey; + renderState.element = node ? renderState.root.render(node) : null; + if (renderState.element) nextChildren.push(renderState.element); } this.replaceChildren(...nextChildren); } diff --git a/src/js/plugins/pluginRichTextDispatcher.js b/src/js/plugins/pluginRichTextDispatcher.js new file mode 100644 index 00000000..7fbb2c52 --- /dev/null +++ b/src/js/plugins/pluginRichTextDispatcher.js @@ -0,0 +1,168 @@ +import { Signal } from "/js/signals.js"; +import { AsyncValueCache, batchPerTick } from "/js/utils.js"; +import { + validateRichTextTokens, + hydrateRichTextFacets, +} from "/js/richTextHelpers.js"; + +const MAX_CACHED_POSTS = 500; + +// Stamps node tokens (inline/block) with the pluginId that created them, so +// renderNodeToken can route each to the correct plugin's renderer. +function stampNodeTokens(tokens, previousTokens, pluginId) { + const stampedIds = new Set(); + // Only pass through previously stamped tokens to prevent cross-plugin forgery + for (const token of previousTokens) { + if (token.type === "inline" || token.type === "block") { + stampedIds.add(token.pluginId); + } + } + return tokens.map((token) => { + if (token.type !== "inline" && token.type !== "block") return token; + return { + ...token, + pluginId: stampedIds.has(token.pluginId) ? token.pluginId : pluginId, + }; + }); +} + +export class PluginRichTextDispatcher { + constructor({ getRenderer }) { + this.$version = new Signal.State(0); + this._getRenderer = getRenderer; + this._transforms = new Set(); + // (uri, surface) -> { text, tokens } + this._cache = new AsyncValueCache(MAX_CACHED_POSTS); + // Run pipeline once per render flush + this._runTransform = batchPerTick((items) => this._runTransforms(items)); + this._elements = new WeakMap(); + } + + // `invoke` takes an array of { tokens, context } and resolves to an array of + // { value } | { error } in the same order. Returns a dispose function. + register({ pluginId, handlesFacetTypes = [], invoke }) { + const transform = { + pluginId, + handlesFacetTypes: Array.isArray(handlesFacetTypes) + ? handlesFacetTypes + : [], + invoke, + }; + this._transforms.add(transform); + this._invalidate(); + return () => { + this._transforms.delete(transform); + this._invalidate(); + }; + } + + // Facet types a transform owns, so the host can hold back rendering the + // fallback text for them and avoid a render flash + getClaimedFacetTypes() { + const types = new Set(); + for (const transform of this._transforms) { + for (const type of transform.handlesFacetTypes) types.add(type); + } + return types; + } + + // Results are cached by (uri, surface); requests are batched per render flush + async transformTokens(tokens, context) { + if (this._transforms.size === 0) return null; + const key = `${context.uri}|${context.surface}`; + // A post's text can potentially change under the same key (an edit), so + // check to make sure it matches here + const cached = this._cache.peek(key); + if (cached && cached.value.text !== context.source.text) { + this._cache.delete(key); + } + // A transform registering mid-run invalidates the cache, so that result is + // never stored - the run resolves null and this render keeps the original tokens + const result = await this._cache.request(key, async () => ({ + text: context.source.text, + tokens: await this._runTransform({ baseTokens: tokens, tokens, context }), + })); + return result.tokens; + } + + // Mounts a node token's VirtualEl via the owning plugin's renderer. + // Elements are cached by host / token + renderNodeToken(token, host) { + if (!token.pluginId || !token.node || !host) return null; + let byToken = this._elements.get(host); + if (!byToken) { + byToken = new WeakMap(); + this._elements.set(host, byToken); + } + let cached = byToken.get(token); + if (!cached) { + let renderer = null; + try { + renderer = this._getRenderer(token.pluginId); + } catch { + return null; + } + cached = { root: renderer.createRoot() }; + byToken.set(token, cached); + } + return cached.root.render(token.node); + } + + _invalidate() { + this._cache.invalidate(); + this.$version.set(this.$version.get() + 1); + } + + async _runTransforms(items) { + const version = this.$version.get(); + for (const transform of this._transforms) { + const batch = items.map((item) => ({ + tokens: item.tokens, + context: item.context, + })); + let results = null; + try { + results = await transform.invoke(batch); + } catch (e) { + console.error( + `Plugin ${transform.pluginId} rich text transform raised an exception`, + e, + ); + } + if (!Array.isArray(results)) continue; + items.forEach((item, index) => { + const result = results[index]; + if (!result || result.error != null) { + if (result?.error != null) { + console.error( + `Plugin ${transform.pluginId} rich text transform failed: ${result.error}`, + ); + } + return; + } + if (!validateRichTextTokens(result.value)) { + console.error( + `Plugin ${transform.pluginId} rich text transform returned malformed tokens`, + ); + return; + } + try { + const hydrated = hydrateRichTextFacets(result.value, item.baseTokens); + item.tokens = stampNodeTokens( + hydrated, + item.tokens, + transform.pluginId, + ); + } catch (error) { + console.error( + `Plugin ${transform.pluginId} rich text transform returned an unrecognized facet`, + error, + ); + } + }); + } + // Discard if transforms changed mid-run + if (version !== this.$version.get()) return items.map(() => null); + return items.map((item) => item.tokens); + } +} diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 60b9760b..9f4b476c 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -18,6 +18,8 @@ import { PluginMemoryDataStore, } from "/js/plugins/pluginLocalDataStore.js"; import { PluginPreferencesManager } from "/js/plugins/pluginPreferencesManager.js"; +import { PluginRichTextDispatcher } from "/js/plugins/pluginRichTextDispatcher.js"; +import { PluginSlotDispatcher } from "/js/plugins/pluginSlotDispatcher.js"; import { SourceProvider } from "/js/plugins/sourceProvider.js"; import { PluginStylesLoader } from "/js/plugins/pluginStylesLoader.js"; import { pluginFetch } from "/js/plugins/pluginRequests.js"; @@ -30,10 +32,6 @@ import { isActionAllowed, } from "/js/plugins/pluginPermissions.js"; import { compareVersions, groupBy, isDev, sortBy } from "/js/utils.js"; -import { - validateRichTextTokens, - hydrateRichTextFacets, -} from "/js/richTextHelpers.js"; import { Signal, SignalMap, SignalSet, ReactiveStore } from "/js/signals.js"; import { EventEmitter } from "/js/eventEmitter.js"; import { PLUGIN_REGISTRY_URL } from "/js/config.js"; @@ -96,25 +94,6 @@ export function parseRepoUrl(input) { return host === "github" ? path : `${host}:${path}`; } -// Stamps node tokens (inline/block) with the pluginId that created them, so -// renderRichTextNodeToken can route each to the correct plugin's renderer. -function stampRichTextNodeTokens(tokens, previousTokens, pluginId) { - const stampedIds = new Set(); - // Only pass through previously stamped tokens to prevent cross-plugin forgery - for (const token of previousTokens) { - if (token.type === "inline" || token.type === "block") { - stampedIds.add(token.pluginId); - } - } - return tokens.map((token) => { - if (token.type !== "inline" && token.type !== "block") return token; - return { - ...token, - pluginId: stampedIds.has(token.pluginId) ? token.pluginId : pluginId, - }; - }); -} - export class PermissionsDeclinedError extends Error { constructor(message = "User declined permissions") { super(message); @@ -131,7 +110,6 @@ export class PluginService extends ReactiveStore { sidebarItems: new SignalSet(), eventListeners: new Map(), feedFilters: new Set(), - richTextTransforms: new Set(), }; this.$availableUpdates = new Signal.State(null); this.$rawRegistryListings = new Signal.State(null); @@ -168,16 +146,11 @@ export class PluginService extends ReactiveStore { hasSettings: this.$settingTabs.get(entry.id) !== null, })); }); - // Bumped whenever a transform registers/unregisters - this.$richTextTransformsVersion = new Signal.State(0); - this._richTextTokensCache = new Map(); - this._pendingRichTextRuns = new Map(); - this._richTextQueue = []; - this._richTextFlushScheduled = false; - this._richTextElements = new WeakMap(); this.$settingTabs = new SignalMap(); - this.$slots = new SignalMap(); - this._slotEntryVersion = 0; + this.slotDispatcher = new PluginSlotDispatcher(); + this.richTextDispatcher = new PluginRichTextDispatcher({ + getRenderer: (pluginId) => this.getRenderer(pluginId), + }); this.localPluginsEnabled = isDev(); this.remoteRegistry = new RemotePluginRegistry(PLUGIN_REGISTRY_URL); this.localRegistry = this.localPluginsEnabled @@ -300,47 +273,22 @@ export class PluginService extends ReactiveStore { }); this.pluginBridge.addRegistrationTarget( "richTextTransform", - (plugin, message) => { - const entry = { + (plugin, message) => + this.richTextDispatcher.register({ pluginId: plugin.pluginId, - handlesFacetTypes: Array.isArray(message.handlesFacetTypes) - ? message.handlesFacetTypes - : [], + handlesFacetTypes: message.handlesFacetTypes, invoke: (batch) => plugin.call(message.handlerId, batch), - }; - this.registries.richTextTransforms.add(entry); - this._invalidateRichTextTransforms(); - return () => { - this.registries.richTextTransforms.delete(entry); - this._invalidateRichTextTransforms(); - }; - }, + }), ); - 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 = { + this.pluginBridge.addRegistrationTarget("slot", (plugin, message) => + this.slotDispatcher.register({ pluginId: plugin.pluginId, - version: ++this._slotEntryVersion, - invoke: (context) => plugin.call(message.handlerId, context), - }; - this.$slots.set(message.name, [...current, entry]); - return () => { - const list = this.$slots.get(message.name); - if (!list) return; - const next = list.filter((other) => other.pluginId !== plugin.pluginId); - if (next.length === 0) { - this.$slots.delete(message.name); - } else { - this.$slots.set(message.name, next); - } - }; - }); + name: message.name, + cacheKey: message.cacheKey, + batch: message.batch === true, + invoke: (payload) => plugin.call(message.handlerId, payload), + }), + ); } _setupHostMethods() { @@ -402,17 +350,8 @@ export class PluginService extends ReactiveStore { }, ); - this.pluginBridge.addHostMethod("refreshSlot", (plugin, { name }) => { - const current = this.$slots.get(name); - 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("refreshSlot", (plugin, { name, keys }) => { + this.slotDispatcher.refresh(plugin.pluginId, name, keys); }); this.pluginBridge.addHostMethod( @@ -1022,8 +961,13 @@ export class PluginService extends ReactiveStore { return [...this.registries.sidebarItems]; } - getSlotEntries(name) { - return [...(this.$slots.get(name) ?? [])]; + // Slot consumers () are handed the service, not the dispatcher + get $slots() { + return this.slotDispatcher.$slots; + } + + getSlotRegistrations(name) { + return this.slotDispatcher.getRegistrations(name); } getSettingTabs() { @@ -1149,138 +1093,22 @@ export class PluginService extends ReactiveStore { ); } - // Rich-text transform pipeline + // Rich-text consumers () are handed the service, not + // the dispatcher - _invalidateRichTextTransforms() { - this._pendingRichTextRuns.clear(); - this._richTextTokensCache.clear(); - this.$richTextTransformsVersion.set( - this.$richTextTransformsVersion.get() + 1, - ); + get $richTextTransformsVersion() { + return this.richTextDispatcher.$version; } getClaimedFacetTypes() { - const types = new Set(); - for (const entry of this.registries.richTextTransforms) { - if (!entry.handlesFacetTypes) continue; - for (const type of entry.handlesFacetTypes) types.add(type); - } - return types; - } - - // Results are cached by (uri, surface); requests are batched per render flush - async transformRichTextTokens(tokens, context) { - if (this.registries.richTextTransforms.size === 0) return null; - const key = `${context.uri}|${context.surface}`; - const cached = this._richTextTokensCache.get(key); - if (cached && cached.text === context.source.text) { - return cached.tokens; - } - const pending = this._pendingRichTextRuns.get(key); - if (pending) return pending; - const item = { key, baseTokens: tokens, tokens, context }; - const promise = new Promise((resolve) => { - item.resolve = resolve; - }); - this._pendingRichTextRuns.set(key, promise); - this._richTextQueue.push(item); - if (!this._richTextFlushScheduled) { - this._richTextFlushScheduled = true; - queueMicrotask(() => { - this._richTextFlushScheduled = false; - const items = this._richTextQueue.splice(0); - this._runRichTextTransforms( - items, - this.$richTextTransformsVersion.get(), - ); - }); - } - return promise; + return this.richTextDispatcher.getClaimedFacetTypes(); } - async _runRichTextTransforms(items, version) { - for (const transform of this.registries.richTextTransforms) { - const batch = items.map((item) => ({ - tokens: item.tokens, - context: item.context, - })); - let results = null; - try { - results = await transform.invoke(batch); - } catch (e) { - console.error( - `Plugin ${transform.pluginId} rich text transform raised an exception`, - e, - ); - } - if (!Array.isArray(results)) continue; - items.forEach((item, index) => { - const result = results[index]; - if (!result || result.error != null) { - if (result?.error != null) { - console.error( - `Plugin ${transform.pluginId} rich text transform failed: ${result.error}`, - ); - } - return; - } - if (!validateRichTextTokens(result.value)) { - console.error( - `Plugin ${transform.pluginId} rich text transform returned malformed tokens`, - ); - return; - } - try { - const hydrated = hydrateRichTextFacets(result.value, item.baseTokens); - item.tokens = stampRichTextNodeTokens( - hydrated, - item.tokens, - transform.pluginId, - ); - } catch (error) { - console.error( - `Plugin ${transform.pluginId} rich text transform returned an unrecognized facet`, - error, - ); - } - }); - } - // Discard if transforms changed mid-run - const isStale = version !== this.$richTextTransformsVersion.get(); - for (const item of items) { - if (isStale) { - item.resolve(null); - continue; - } - this._pendingRichTextRuns.delete(item.key); - this._richTextTokensCache.set(item.key, { - text: item.context.source.text, - tokens: item.tokens, - }); - item.resolve(item.tokens); - } + transformRichTextTokens(tokens, context) { + return this.richTextDispatcher.transformTokens(tokens, context); } - // Mounts a node token's VirtualEl via the owning plugin's renderer. - // Elements are cached by host / token renderRichTextNodeToken(token, host) { - if (!token.pluginId || !token.node || !host) return null; - let byToken = this._richTextElements.get(host); - if (!byToken) { - byToken = new WeakMap(); - this._richTextElements.set(host, byToken); - } - let cached = byToken.get(token); - if (!cached) { - let renderer = null; - try { - renderer = this.getRenderer(token.pluginId); - } catch { - return null; - } - cached = { root: renderer.createRoot() }; - byToken.set(token, cached); - } - return cached.root.render(token.node); + return this.richTextDispatcher.renderNodeToken(token, host); } } diff --git a/src/js/plugins/pluginSlotDispatcher.js b/src/js/plugins/pluginSlotDispatcher.js new file mode 100644 index 00000000..5d8482d4 --- /dev/null +++ b/src/js/plugins/pluginSlotDispatcher.js @@ -0,0 +1,344 @@ +import { SignalMap } from "/js/signals.js"; +import { + AsyncValueCache, + batchPerTick, + BoundedMap, + isDev, + SimpleUUID, +} from "/js/utils.js"; + +// Rendered content cached per cacheKey-declaring registration +const MAX_CACHED_VALUES = 200; +// Contexts remembered per (plugin, slot), for keyed refresh targeting +const MAX_TRACKED_CONTEXTS = 600; + +// Slot contexts are flat string maps (element attributes), so sorting the +// fields is enough to make the serialization stable. +function serializeFields(context) { + return JSON.stringify( + Object.keys(context) + .sort() + .map((field) => [field, context[field]]), + ); +} + +function deserializeFields(serialized) { + return Object.fromEntries(JSON.parse(serialized)); +} + +// Subset match: a context matches when every field of some matcher equals the +// context's field of that name +function matchesKeys(keys, context) { + return keys.some((matcher) => + Object.entries(matcher).every(([field, value]) => context[field] === value), + ); +} + +function createSlotId(pluginId, name) { + return JSON.stringify([pluginId, name]); +} + +// The context reduced to a registration's declared cacheKey fields - the only +// thing its output is allowed to depend on, and so what its content is cached +// and invalidated by +function projectContext(context, cacheKey) { + const projection = {}; + for (const field of cacheKey) { + if (context[field] !== undefined) projection[field] = context[field]; + } + return projection; +} + +// Shared across registrations so a re-registered slot never reuses a version +// a mounted element may still be holding +const versionUuid = new SimpleUUID(); + +// Tracks plugin-slot version per context to enable targeted re-renders +class SlotVersions { + constructor() { + this._base = versionUuid.create(); + // contextKey -> { context, version }: forgetting one would mean a later + // keyed refresh missing an instance that's still mounted, so an eviction + // downgrades the next refresh to "invalidate everything" + this._contexts = new BoundedMap(MAX_TRACKED_CONTEXTS, { + policy: "lru", + onEvict: () => { + this._truncated = true; + }, + }); + this._truncated = false; + } + + lookup(context) { + const contextKey = serializeFields(context); + const known = this._contexts.get(contextKey); + if (known) return known.version; + this._contexts.set(contextKey, { context, version: this._base }); + return this._base; + } + + invalidate(keys) { + if (!keys || this._truncated) { + this._base = versionUuid.create(); + this._contexts.clear(); + this._truncated = false; + return; + } + for (const tracked of this._contexts.values()) { + if (matchesKeys(keys, tracked.context)) { + tracked.version = versionUuid.create(); + } + } + } +} + +// e.g. [{ did: "..." }, ...] +// Returns the matchers, or null when every context should match. +// Throws when the plugin passed something unusable. +function validateRefreshKeys(keys, { pluginId, name, cacheKey }) { + if (keys == null) return null; + const fail = (reason) => { + throw new Error( + `Plugin "${pluginId}" called refreshSlot("${name}") with invalid keys: ${reason}`, + ); + }; + if (!Array.isArray(keys)) fail("keys must be an array of objects"); + if (keys.length === 0) fail("keys must not be empty"); + for (const matcher of keys) { + if (!matcher || typeof matcher !== "object" || Array.isArray(matcher)) { + fail("each key must be an object"); + } + const fields = Object.keys(matcher); + if (fields.length === 0) { + fail("empty matchers are not allowed; omit keys to match all"); + } + if (fields.some((field) => typeof matcher[field] !== "string")) { + fail("matcher values must be strings"); + } + // A cacheKey declares the only fields the output may depend on, so + // matching on anything else can't mean what the plugin thinks it does + if (cacheKey === null) continue; + if (cacheKey.length === 0) { + fail( + "this slot declares an empty cacheKey, so its content depends on no context; omit keys to refresh it", + ); + } + const undeclared = fields.filter((field) => !cacheKey.includes(field)); + if (undeclared.length > 0) { + fail( + `this slot declares cacheKey (${cacheKey.join(", ")}), so keys can't match on ${undeclared.join(", ")}`, + ); + } + } + return keys; +} + +// A batching plugin answers with `{ value } | { error }` per payload - +// validate length and convert to the value-or-Error +function batchedInvoke(pluginId, invoke) { + return async (payloads) => { + const results = await invoke(payloads); + // Results are matched positionally, so a short array can't be attributed + if (!Array.isArray(results) || results.length !== payloads.length) { + throw new Error( + `Plugin "${pluginId}" returned a malformed slot batch result`, + ); + } + return results.map((result) => { + if (result?.error == null) return result?.value ?? null; + return result.error instanceof Error + ? result.error + : new Error(result.error); + }); + }; +} + +function invocationAdvice(cacheKey) { + if (cacheKey === null) { + return "Declare a cacheKey so instances sharing a projection share one invocation."; + } + if (cacheKey.length === 0) { + return "Its empty cacheKey means one cached entry serves every instance, so something is invalidating it repeatedly."; + } + return `Its cacheKey (${cacheKey.join(", ")}) may be too specific to share results.`; +} + +const INVOCATION_WINDOW_MS = 5000; +const REPEAT_CONTEXT_LIMIT = 5; +const TOTAL_INVOCATION_LIMIT = 100; + +// Warns when a slot handler runs too often in a given window +export class SlotInvocationMonitor { + constructor() { + this.buckets = new Map(); + } + + record(registration, name, context) { + const id = createSlotId(registration.pluginId, name); + const now = Date.now(); + let bucket = this.buckets.get(id); + if (!bucket || now - bucket.startedAt > INVOCATION_WINDOW_MS) { + bucket = { startedAt: now, total: 0, contexts: new Map(), warned: false }; + this.buckets.set(id, bucket); + } + bucket.total += 1; + const contextKey = serializeFields(context); + const repeats = (bucket.contexts.get(contextKey) ?? 0) + 1; + bucket.contexts.set(contextKey, repeats); + if (bucket.warned) return; + const seconds = INVOCATION_WINDOW_MS / 1000; + const prefix = `[plugins] "${registration.pluginId}" slot "${name}" ran`; + if (repeats >= REPEAT_CONTEXT_LIMIT) { + bucket.warned = true; + console.warn( + `${prefix} ${repeats} times for the same context in ${seconds}s: ${contextKey}. Look for a refreshSlot loop, or a context attribute that changes on every render.`, + ); + return; + } + if (bucket.total >= TOTAL_INVOCATION_LIMIT) { + bucket.warned = true; + const advice = invocationAdvice(registration.cacheKey); + console.warn( + `${prefix} ${bucket.total} times in ${seconds}s across ${bucket.contexts.size} contexts. ${advice}`, + ); + } + } +} + +export class PluginSlotDispatcher { + constructor({ monitor = isDev() ? new SlotInvocationMonitor() : null } = {}) { + this.$slots = new SignalMap(); + // [pluginId, name] -> AsyncValueCache of rendered content by projection + this._caches = new Map(); + // [pluginId, name] -> SlotVersions + this._versions = new Map(); + // [pluginId, name] -> (payload) => rendered tree + this._handlers = new Map(); + this._monitor = monitor; + } + + register({ pluginId, name, cacheKey = null, batch = false, invoke }) { + const current = this.$slots.get(name) ?? []; + if (current.some((other) => other.pluginId === pluginId)) { + console.warn( + `"${pluginId}" is already registered for slot "${name}"; ignoring duplicate registration`, + ); + return null; + } + const fields = Array.isArray(cacheKey) + ? cacheKey.filter((field) => typeof field === "string") + : null; + // Batching plugins get their calls coalesced per tick into one worker message; + // plugins on an older SDK take one payload per call, so their invoke is already this. + this._handlers.set( + createSlotId(pluginId, name), + batch ? batchPerTick(batchedInvoke(pluginId, invoke)) : invoke, + ); + const versions = this._getSlotVersions(pluginId, name); + const registration = { + pluginId, + cacheKey: fields, + versionFor: (context) => versions.lookup(context), + contextKeyFor: (context) => + serializeFields( + fields === null ? context : projectContext(context, fields), + ), + request: (context) => this._request(registration, name, context), + }; + this.$slots.set(name, [...current, registration]); + return () => { + const id = createSlotId(pluginId, name); + this._caches.delete(id); + this._versions.delete(id); + this._handlers.delete(id); + const list = this.$slots.get(name); + if (!list) return; + const next = list.filter((other) => other.pluginId !== pluginId); + if (next.length === 0) { + this.$slots.delete(name); + } else { + this.$slots.set(name, next); + } + }; + } + + getRegistrations(name) { + return [...(this.$slots.get(name) ?? [])]; + } + + // Drops the calling plugin's cached content for the slot, matching on keys + refresh(pluginId, name, keys) { + const current = this.$slots.get(name); + if (!current) return; + const registration = current.find( + (candidate) => candidate.pluginId === pluginId, + ); + if (!registration) return; + let matchers = null; + try { + matchers = validateRefreshKeys(keys, { + pluginId, + name, + cacheKey: registration.cacheKey, + }); + } catch (error) { + console.warn(error.message); + return; + } + const matchesProjection = + matchers && + ((projectionKey) => + matchesKeys(matchers, deserializeFields(projectionKey))); + const contentCache = this._caches.get(createSlotId(pluginId, name)); + if (contentCache) { + contentCache.invalidate(matchesProjection); + } + const versions = this._getSlotVersions(pluginId, name); + versions.invalidate(matchers); + // Re-emit so mounted slots reconcile; which of them re-invoke is decided + // by the versions above + this.$slots.set(name, [...current]); + } + + // The rendered tree for this context, or a promise of it + _request(registration, name, context) { + if (registration.cacheKey === null) { + return this._getSlotContent(registration, name, context); + } + const projection = projectContext(context, registration.cacheKey); + const contentCache = this._getSlotContentCache(registration, name); + // Get fresh or cached slot content, keyed by cache keys + return contentCache.request(serializeFields(projection), () => + this._getSlotContent(registration, name, projection), + ); + } + + _getSlotVersions(pluginId, name) { + const id = createSlotId(pluginId, name); + let versions = this._versions.get(id); + if (!versions) { + versions = new SlotVersions(); + this._versions.set(id, versions); + } + return versions; + } + + _getSlotContentCache(registration, name) { + const id = createSlotId(registration.pluginId, name); + let cache = this._caches.get(id); + if (!cache) { + cache = new AsyncValueCache(MAX_CACHED_VALUES); + this._caches.set(id, cache); + } + return cache; + } + + _getSlotContent(registration, name, payload) { + if (this._monitor) { + this._monitor.record(registration, name, payload); + } + return this._handlers.get(createSlotId(registration.pluginId, name))( + payload, + ); + } +} diff --git a/src/js/router.js b/src/js/router.js index bf92d1ad..08250ced 100644 --- a/src/js/router.js +++ b/src/js/router.js @@ -1,5 +1,6 @@ import { EventEmitter, EventTarget } from "/js/eventEmitter.js"; import { effect, Signal } from "/js/signals.js"; +import { BoundedMap } from "/js/utils.js"; const MAX_PAGES = 5; @@ -94,7 +95,10 @@ export class Router extends EventEmitter { this.currentPage = null; this.currentPath = null; this.$currentRoute = new Signal.State(null); - this.pages = new Map(); + this.pages = new BoundedMap(MAX_PAGES, { + policy: "lru", + onEvict: (path, page) => page.el.remove(), + }); this.scrollStates = new Map(); bindMiddleClickRedispatch(); // Disable scroll restoration @@ -263,10 +267,6 @@ export class Router extends EventEmitter { // Return to existing page const { el: page, routeInfo } = this.pages.get(path); this.currentPage = page; - // Re-insert the page so it's at the end of the stack - // This means the least recently used page is always at the start of the stack - this.pages.delete(path); - this.pages.set(path, { el: page, routeInfo }); this.$currentRoute.set({ path, ...routeInfo }); this.#setLayoutHidden(routeInfo.options.layout === false); const scrollY = this.scrollStates.get(path) ?? 0; @@ -298,13 +298,6 @@ export class Router extends EventEmitter { container.appendChild(newPage); this.currentPage = newPage; this.pages.set(path, { el: newPage, routeInfo }); - // Limit stored pages to prevent memory leaks / performance issues - if (this.pages.size > MAX_PAGES) { - const firstPageKey = this.pages.keys().next().value; - const firstPage = this.pages.get(firstPageKey); - firstPage.el.remove(); - this.pages.delete(firstPageKey); - } window.scrollTo(0, 0); await this.renderFunc({ view, diff --git a/src/js/utils.js b/src/js/utils.js index 1e673afe..11bd5f09 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -286,6 +286,140 @@ export function throttle(fn, delay = 250) { }; } +export class BoundedMap extends Map { + constructor(maxSize, { onEvict = noop, policy = "fifo" } = {}) { + super(); + this.maxSize = maxSize; + this._onEvict = onEvict; + this._policy = policy; + } + + get(key) { + if (this._policy !== "lru" || !super.has(key)) return super.get(key); + const value = super.get(key); + super.delete(key); + super.set(key, value); + return value; + } + + peek(key) { + return super.get(key); + } + + set(key, value) { + super.set(key, value); + while (this.size > this.maxSize) { + const oldestKey = this.keys().next().value; + const oldestValue = this.peek(oldestKey); + this.delete(oldestKey); + this._onEvict(oldestKey, oldestValue); + } + return this; + } +} + +export function isPromise(value) { + return typeof value?.then === "function"; +} + +// Cache of async results: a hit is returned synchronously, +// a miss (or an in-flight run for the same key) returns a promise +export class AsyncValueCache { + constructor(maxSize) { + // key -> value + this._values = new BoundedMap(maxSize, { policy: "lru" }); + // key -> promise + this._pending = new Map(); + } + + // Returns the value itself when it's already known, otherwise a promise - + // callers that can render synchronously check with isPromise() + request(key, run) { + const cached = this._values.get(key); + if (cached) return cached.value; + const pending = this._pending.get(key); + if (pending) return pending; + const promise = run().then( + (value) => { + if (this._pending.get(key) === promise) { + this._pending.delete(key); + this._values.delete(key); + this._values.set(key, { value }); + } + return value; + }, + (error) => { + if (this._pending.get(key) === promise) this._pending.delete(key); + throw error; + }, + ); + this._pending.set(key, promise); + return promise; + } + + invalidate(matchFn) { + if (!matchFn) { + this._values.clear(); + this._pending.clear(); + return; + } + for (const key of [...this._values.keys(), ...this._pending.keys()]) { + if (!matchFn(key)) continue; + this._values.delete(key); + this._pending.delete(key); + } + } + + // Reads without recording use, for callers that validate before trusting + peek(key) { + return this._values.peek(key) ?? null; + } + + delete(key) { + this._values.delete(key); + this._pending.delete(key); + } + + get size() { + return this._values.size; + } +} + +// Turns a batch function into a single-item function - +// calls made in the same microtask are collected and run together, +// then results are resolved / rejected individually +export function batchPerTick(runBatch) { + let queued = []; + let scheduled = false; + return (item) => + new Promise((resolve, reject) => { + queued.push({ item, resolve, reject }); + if (scheduled) return; + scheduled = true; + queueMicrotask(async () => { + scheduled = false; + const batch = queued; + queued = []; + try { + const results = await runBatch(batch.map((entry) => entry.item)); + if (!Array.isArray(results) || results.length !== batch.length) { + throw new Error( + `batchPerTick expected ${batch.length} results, got ${results?.length}`, + ); + } + batch.forEach((entry, index) => { + const result = results[index]; + if (result instanceof Error) entry.reject(result); + else entry.resolve(result); + }); + } catch (error) { + // If batch fn fails, reject all promises + for (const entry of batch) entry.reject(error); + } + }); + }); +} + export function formatNumNotifications(numNotifications) { if (numNotifications >= 30) { return "30+"; diff --git a/tests/e2e/specs/concerns/pluginSlotCache.test.js b/tests/e2e/specs/concerns/pluginSlotCache.test.js new file mode 100644 index 00000000..a87726c1 --- /dev/null +++ b/tests/e2e/specs/concerns/pluginSlotCache.test.js @@ -0,0 +1,136 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; +import { createPost } from "../../../shared/factories.js"; +import { + TEST_PLUGIN_MANIFEST, + getBadgeSlotPluginSource, + getUncachedBadgeSlotPluginSource, +} from "../../testPlugin.js"; + +const AUTHOR_ONE = "did:plc:author1"; +const AUTHOR_TWO = "did:plc:author2"; + +// Two posts by the same author plus one by another, so a did-projected slot +// should run its handler twice for three rendered badges. +function setupFeed(mockServer, { cacheKey = true } = {}) { + mockServer.installedPlugins = [{ ...TEST_PLUGIN_MANIFEST, enabled: true }]; + mockServer.localPluginSource = cacheKey + ? getBadgeSlotPluginSource() + : getUncachedBadgeSlotPluginSource(); + mockServer.addTimelinePosts([ + createPost({ + uri: `at://${AUTHOR_ONE}/app.bsky.feed.post/post1`, + text: "First post", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + }), + createPost({ + uri: `at://${AUTHOR_ONE}/app.bsky.feed.post/post2`, + text: "Second post", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + }), + createPost({ + uri: `at://${AUTHOR_TWO}/app.bsky.feed.post/post3`, + text: "Third post", + authorHandle: "author2.bsky.social", + authorDisplayName: "Author Two", + }), + ]); +} + +function badges(page) { + return page.locator('#home-view [data-testid="plugin-badge"]'); +} + +test.describe("Plugin slot caching", () => { + test("shares one invocation across posts by the same author", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupFeed(mockServer); + await mockServer.setup(page); + + await login(page); + await page.goto("/"); + + await expect(badges(page)).toHaveCount(3, { timeout: 10000 }); + const texts = await badges(page).allTextContents(); + // Both did:plc:author1 badges carry the same invocation number. + expect(texts[0]).toEqual(texts[1]); + expect(texts[0]).toContain(AUTHOR_ONE); + expect(texts[2]).toContain(AUTHOR_TWO); + const invocations = texts.map((text) => Number(text.split("#")[1])); + expect(new Set(invocations).size).toEqual(2); + expect(Math.max(...invocations)).toEqual(2); + }); + + test("a keyed refresh re-runs one author's badge across its posts", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupFeed(mockServer); + await mockServer.setup(page); + + await login(page); + await page.goto("/"); + + await expect(badges(page)).toHaveCount(3, { timeout: 10000 }); + const before = await badges(page).allTextContents(); + + const firstPost = page + .locator('#home-view [data-testid="feed-item"]') + .first(); + await firstPost.locator('[data-testid="post-action-more"]').click(); + await page + .locator(".post-context-menu context-menu-item", { + hasText: "Refresh badge", + }) + .click(); + + // The refresh is keyed on the clicked post's author, so both of that + // author's badges re-run - and only theirs. + await expect(badges(page).first()).not.toHaveText(before[0], { + timeout: 10000, + }); + const after = await badges(page).allTextContents(); + expect(after[1]).toEqual(after[0]); + expect(after[2]).toEqual(before[2]); + }); + + test("a keyed refresh targets instances of a slot with no cacheKey", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupFeed(mockServer, { cacheKey: false }); + await mockServer.setup(page); + + await login(page); + await page.goto("/"); + + await expect(badges(page)).toHaveCount(3, { timeout: 10000 }); + // Without a cacheKey every instance runs its own handler + const before = await badges(page).allTextContents(); + expect(new Set(before).size).toEqual(3); + + const firstPost = page + .locator('#home-view [data-testid="feed-item"]') + .first(); + await firstPost.locator('[data-testid="post-action-more"]').click(); + await page + .locator(".post-context-menu context-menu-item", { + hasText: "Refresh badge", + }) + .click(); + + // Only the clicked author's instances re-invoke; the third post keeps the + // content it already rendered + await expect(badges(page).first()).not.toHaveText(before[0], { + timeout: 10000, + }); + const after = await badges(page).allTextContents(); + expect(after[1]).not.toEqual(before[1]); + expect(after[2]).toEqual(before[2]); + }); +}); diff --git a/tests/e2e/testPlugin.js b/tests/e2e/testPlugin.js index 0ce2b07f..f7177ab4 100644 --- a/tests/e2e/testPlugin.js +++ b/tests/e2e/testPlugin.js @@ -161,6 +161,48 @@ class TestPlugin extends Plugin { TestPlugin.register(); `; +// A plugin that badges every rendered author with a cacheKey-declared slot. +// The badge text carries a global invocation counter, so tests can tell a +// shared cache hit (same number) from a fresh handler run (higher number). +// Its post context-menu item refreshes only the clicked post's author. +const BADGE_SLOT_PLUGIN_BODY = /* js */ ` +let invocations = 0; + +class TestPlugin extends Plugin { + async onload() { + this.registerSlot( + "author-badges", + (context) => { + invocations += 1; + const el = new VirtualEl("span"); + el.setAttr("data-testid", "plugin-badge"); + el.setText(context.did + " #" + invocations); + return el; + }, + { cacheKey: ["did"] }, + ); + this.app.on("post-context-menu", (menu, post) => { + menu.addItem((item) => + item.setTitle("Refresh badge").onClick(() => { + this.refreshSlot("author-badges", { + keys: [{ did: post.author.did }], + }); + }), + ); + }); + } +} + +TestPlugin.register(); +`; + +// The same badge plugin without a cacheKey: nothing is cached and nothing is +// shared, so a keyed refresh has only the per-context versions to target with. +const UNCACHED_BADGE_SLOT_PLUGIN_BODY = BADGE_SLOT_PLUGIN_BODY.replace( + ' { cacheKey: ["did"] },\n', + "", +); + let cachedWorkerSource = null; function getWorkerSource() { @@ -187,3 +229,11 @@ export function getNoSettingsPluginSource() { export function getPostComposerInitPluginSource() { return getWorkerSource() + "\n" + POST_COMPOSER_INIT_PLUGIN_BODY; } + +export function getBadgeSlotPluginSource() { + return getWorkerSource() + "\n" + BADGE_SLOT_PLUGIN_BODY; +} + +export function getUncachedBadgeSlotPluginSource() { + return getWorkerSource() + "\n" + UNCACHED_BADGE_SLOT_PLUGIN_BODY; +} diff --git a/tests/unit/specs/components/plugin-profiles-list.test.js b/tests/unit/specs/components/plugin-profiles-list.test.js index 53f44749..8c3aed68 100644 --- a/tests/unit/specs/components/plugin-profiles-list.test.js +++ b/tests/unit/specs/components/plugin-profiles-list.test.js @@ -1,17 +1,10 @@ import { describe, it, beforeEach, mock } from "node:test"; import assert from "node:assert/strict"; import "/js/components/plugin-profiles-list.js"; -import { makeTestDataLayer } from "../../testHelpers.js"; - -function makeStubPluginService() { - return { - $slots: { get: () => null }, - getSlotEntries: () => [], - }; -} +import { makeTestDataLayer, makeTestPluginService } from "../../testHelpers.js"; function mount(element, dataLayer) { - element.renderContext = { dataLayer, pluginService: makeStubPluginService() }; + element.renderContext = { dataLayer, pluginService: makeTestPluginService() }; document.body.appendChild(element); return element; } diff --git a/tests/unit/specs/components/plugin-rich-text.test.js b/tests/unit/specs/components/plugin-rich-text.test.js index 03432535..561c3047 100644 --- a/tests/unit/specs/components/plugin-rich-text.test.js +++ b/tests/unit/specs/components/plugin-rich-text.test.js @@ -1,6 +1,6 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; -import { Signal } from "/js/signals.js"; +import { makeTestPluginService } from "../../testHelpers.js"; import "/js/components/plugin-rich-text.js"; describe("plugin-rich-text", () => { @@ -26,8 +26,7 @@ describe("plugin-rich-text", () => { result = null, claimedFacetTypes = new Set(), } = {}) { - return { - $richTextTransformsVersion: new Signal.State(0), + return makeTestPluginService({ calls: [], result, claimedFacetTypes, @@ -43,7 +42,7 @@ describe("plugin-rich-text", () => { element.textContent = token.node.text ?? ""; return element; }, - }; + }); } function makeTransformContext(overrides = {}) { diff --git a/tests/unit/specs/components/plugin-slot.test.js b/tests/unit/specs/components/plugin-slot.test.js index d787c4d2..600280ea 100644 --- a/tests/unit/specs/components/plugin-slot.test.js +++ b/tests/unit/specs/components/plugin-slot.test.js @@ -1,6 +1,7 @@ import { describe, it, beforeEach } from "node:test"; import assert from "node:assert/strict"; import { SignalMap } from "/js/signals.js"; +import { makeTestPluginService } from "../../testHelpers.js"; import "/js/components/plugin-slot.js"; describe("plugin-slot", () => { @@ -33,23 +34,38 @@ describe("plugin-slot", () => { }; } - function makePluginService({ entries = {}, onCreateRoot } = {}) { + // Registrations are declared with just `invoke` (or a `cached` value, + // mimicking a dispatcher-level cache hit, which resolves synchronously). + function toRegistration(registration) { + if (registration.request) return registration; + return { + versionFor: () => registration.version ?? 0, + contextKeyFor: (context) => JSON.stringify(context), + ...registration, + request: (context) => + registration.cached + ? registration.cached(context) + : registration.invoke(context), + }; + } + + function makePluginService({ registrations = {}, onCreateRoot } = {}) { const $slots = new SignalMap(); - for (const [name, list] of Object.entries(entries)) { + for (const [name, list] of Object.entries(registrations)) { $slots.set(name, [...list]); } - return { + return makeTestPluginService({ $slots, - setSlotEntries(name, list) { + setSlotRegistrations(name, list) { $slots.set(name, list.length === 0 ? null : [...list]); }, - getSlotEntries(name) { - return [...($slots.get(name) ?? [])]; + getSlotRegistrations(name) { + return ($slots.get(name) ?? []).map(toRegistration); }, getRenderer(pluginId) { return makeRenderer(pluginId, { onCreateRoot }); }, - }; + }); } function makeSlot({ pluginService, name, context = {} }) { @@ -82,7 +98,7 @@ describe("plugin-slot", () => { it("calls each registered plugin with the parsed context", async () => { const calls = []; const pluginService = makePluginService({ - entries: { + registrations: { x: [ { pluginId: "alpha", @@ -114,7 +130,7 @@ describe("plugin-slot", () => { it("renders multiple plugins in registration order", async () => { const pluginService = makePluginService({ - entries: { + registrations: { x: [ { pluginId: "alpha", @@ -137,7 +153,7 @@ describe("plugin-slot", () => { it("skips plugins that return null", async () => { const pluginService = makePluginService({ - entries: { + registrations: { x: [ { pluginId: "alpha", invoke: async () => null }, { @@ -156,7 +172,7 @@ describe("plugin-slot", () => { it("isolates failing plugins from succeeding ones", async () => { const pluginService = makePluginService({ - entries: { + registrations: { x: [ { pluginId: "alpha", @@ -183,17 +199,47 @@ describe("plugin-slot", () => { assert.deepEqual(slot.children.length, 1); assert.deepEqual(slot.children[0].dataset.plugin, "beta"); }); + + it("isolates a plugin whose request throws synchronously", async () => { + const pluginService = makePluginService({ + registrations: { + x: [ + { + pluginId: "alpha", + cached: () => { + throw new Error("boom"); + }, + }, + { + pluginId: "beta", + cached: () => ({ tag: "div", text: "B" }), + }, + ], + }, + }); + const slot = makeSlot({ pluginService, name: "x" }); + const originalError = console.error; + console.error = () => {}; + document.body.appendChild(slot); + try { + await flushMicrotasks(); + } finally { + console.error = originalError; + } + assert.deepEqual(slot.children.length, 1); + assert.deepEqual(slot.children[0].dataset.plugin, "beta"); + }); }); describe("PluginSlot - dynamic updates", () => { it("re-renders when a new plugin registers for this slot", async () => { - const pluginService = makePluginService({ entries: { x: [] } }); + const pluginService = makePluginService({ registrations: { x: [] } }); const slot = makeSlot({ pluginService, name: "x" }); document.body.appendChild(slot); await flushMicrotasks(); assert.deepEqual(slot.children.length, 0); - pluginService.setSlotEntries("x", [ + pluginService.setSlotRegistrations("x", [ { pluginId: "alpha", invoke: async () => ({ tag: "div", text: "A" }) }, ]); await flushMicrotasks(); @@ -202,13 +248,13 @@ describe("plugin-slot", () => { }); it("ignores registrations for other slot names", async () => { - const pluginService = makePluginService({ entries: { x: [] } }); + const pluginService = makePluginService({ registrations: { x: [] } }); const slot = makeSlot({ pluginService, name: "x" }); document.body.appendChild(slot); await flushMicrotasks(); let invoked = false; - pluginService.setSlotEntries("y", [ + pluginService.setSlotRegistrations("y", [ { pluginId: "other", invoke: async () => { @@ -224,7 +270,7 @@ describe("plugin-slot", () => { it("re-renders when the context changes", async () => { const captured = []; const pluginService = makePluginService({ - entries: { + registrations: { x: [ { pluginId: "alpha", @@ -254,7 +300,7 @@ describe("plugin-slot", () => { it("re-renders when context-did changes on an existing element", async () => { const captured = []; const pluginService = makePluginService({ - entries: { + registrations: { "author-badges": [ { pluginId: "alpha", @@ -281,10 +327,48 @@ describe("plugin-slot", () => { assert.deepEqual(slot.children[0].textContent, "did:plc:two"); }); + it("leaves a registration alone when the context changes outside its cacheKey", async () => { + const captured = []; + const pluginService = makePluginService({ + registrations: { + "author-badges": [ + { + pluginId: "alpha", + // Mirrors a registration declaring cacheKey: ["did"] + contextKeyFor: (context) => context.did, + invoke: async (context) => { + captured.push(context); + return { tag: "div", text: context.did }; + }, + }, + ], + }, + }); + const slot = makeSlot({ + pluginService, + name: "author-badges", + context: { did: "did:plc:one", uri: "at://one" }, + }); + document.body.appendChild(slot); + await flushMicrotasks(); + assert.deepEqual(captured.length, 1); + const rendered = slot.children[0]; + + slot.setAttribute("context-uri", "at://two"); + await flushMicrotasks(); + assert.deepEqual(captured.length, 1); + assert.equal(slot.children[0], rendered); + + slot.setAttribute("context-did", "did:plc:two"); + await flushMicrotasks(); + assert.deepEqual(captured.length, 2); + 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: { + registrations: { "author-badges": [ { pluginId: "alpha", @@ -319,9 +403,9 @@ describe("plugin-slot", () => { }); describe("PluginSlot - per-plugin refresh", () => { - function makeVersionedEntries() { + function makeVersionedRegistrations() { const invokeCounts = { alpha: 0, beta: 0 }; - const entries = [ + const registrations = [ { pluginId: "alpha", version: 0, @@ -339,20 +423,22 @@ describe("plugin-slot", () => { }, }, ]; - return { entries, invokeCounts }; + return { registrations, invokeCounts }; } - it("re-invokes only the plugin whose entry version was bumped", async () => { - const { entries, invokeCounts } = makeVersionedEntries(); - const pluginService = makePluginService({ entries: { x: entries } }); + it("re-invokes only the plugin whose registration version was bumped", async () => { + const { registrations, invokeCounts } = makeVersionedRegistrations(); + const pluginService = makePluginService({ + registrations: { x: registrations }, + }); 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); + registrations[0].version += 1; + pluginService.setSlotRegistrations("x", registrations); await flushMicrotasks(); assert.deepEqual(invokeCounts, { alpha: 2, beta: 1 }); assert.deepEqual(slot.children[0].textContent, "A2"); @@ -360,21 +446,25 @@ describe("plugin-slot", () => { }); 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 { registrations, invokeCounts } = makeVersionedRegistrations(); + const pluginService = makePluginService({ + registrations: { x: registrations }, + }); const slot = makeSlot({ pluginService, name: "x" }); document.body.appendChild(slot); await flushMicrotasks(); - pluginService.setSlotEntries("x", entries); + pluginService.setSlotRegistrations("x", registrations); 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 { registrations, invokeCounts } = makeVersionedRegistrations(); + const pluginService = makePluginService({ + registrations: { x: registrations }, + }); const slot = makeSlot({ pluginService, name: "x", @@ -389,6 +479,95 @@ describe("plugin-slot", () => { }); }); + describe("PluginSlot - cached results", () => { + it("renders a cached result without awaiting", () => { + const pluginService = makePluginService({ + registrations: { + x: [ + { + pluginId: "alpha", + cached: (context) => ({ tag: "div", text: context.did }), + }, + ], + }, + }); + const slot = makeSlot({ + pluginService, + name: "x", + context: { did: "did:plc:one" }, + }); + document.body.appendChild(slot); + assert.deepEqual(slot.children.length, 1); + assert.deepEqual(slot.children[0].textContent, "did:plc:one"); + }); + + it("keeps the previous content on screen while a pending registration resolves", async () => { + let resolveSecond = null; + const pluginService = makePluginService({ + registrations: { + x: [ + { + pluginId: "alpha", + invoke: (context) => + context.did === "did:plc:one" + ? Promise.resolve({ tag: "div", text: "first" }) + : new Promise((resolve) => { + resolveSecond = resolve; + }), + }, + ], + }, + }); + const slot = makeSlot({ + pluginService, + name: "x", + context: { did: "did:plc:one" }, + }); + document.body.appendChild(slot); + await flushMicrotasks(); + assert.deepEqual(slot.children[0].textContent, "first"); + + slot.setAttribute("context-did", "did:plc:two"); + await flushMicrotasks(); + assert.deepEqual(slot.children[0].textContent, "first"); + + resolveSecond({ tag: "div", text: "second" }); + await flushMicrotasks(); + assert.deepEqual(slot.children[0].textContent, "second"); + }); + + it("waits for a pending registration before rendering a cached sibling", async () => { + let resolveAlpha = null; + const pluginService = makePluginService({ + registrations: { + x: [ + { + pluginId: "alpha", + invoke: () => + new Promise((resolve) => { + resolveAlpha = resolve; + }), + }, + { + pluginId: "beta", + cached: () => ({ tag: "div", text: "B" }), + }, + ], + }, + }); + const slot = makeSlot({ pluginService, name: "x" }); + document.body.appendChild(slot); + assert.deepEqual(slot.children.length, 0); + + resolveAlpha({ tag: "div", text: "A" }); + await flushMicrotasks(); + assert.deepEqual( + [...slot.children].map((child) => child.textContent), + ["A", "B"], + ); + }); + }); + describe("PluginSlot - initialization", () => { it("throws when pluginService is not set", () => { const element = document.createElement("plugin-slot"); @@ -406,7 +585,7 @@ describe("plugin-slot", () => { describe("PluginSlot - cleanup", () => { it("unsubscribes from the slot signal on disconnect", async () => { - const pluginService = makePluginService({ entries: { x: [] } }); + const pluginService = makePluginService({ registrations: { x: [] } }); const slot = makeSlot({ pluginService, name: "x" }); document.body.appendChild(slot); await flushMicrotasks(); @@ -414,7 +593,7 @@ describe("plugin-slot", () => { // After removal, signal updates should not trigger reconcile. let invoked = false; - pluginService.setSlotEntries("x", [ + pluginService.setSlotRegistrations("x", [ { pluginId: "alpha", invoke: async () => { @@ -429,7 +608,7 @@ describe("plugin-slot", () => { it("re-subscribes and re-renders when reconnected after a disconnect", async () => { const pluginService = makePluginService({ - entries: { + registrations: { x: [ { pluginId: "alpha", @@ -450,7 +629,7 @@ describe("plugin-slot", () => { assert.deepEqual(slot.children[0].textContent, "A"); // The reconnected subscription must also react to later registrations. - pluginService.setSlotEntries("x", [ + pluginService.setSlotRegistrations("x", [ { pluginId: "alpha", invoke: async () => ({ tag: "div", text: "A2" }) }, { pluginId: "beta", invoke: async () => ({ tag: "div", text: "B" }) }, ]); @@ -462,7 +641,7 @@ describe("plugin-slot", () => { // Mimics infinite-scroll-container, which moves its children into an // inner wrapper during its own connectedCallback. const pluginService = makePluginService({ - entries: { + registrations: { x: [ { pluginId: "alpha", diff --git a/tests/unit/specs/components/post-composer.test.js b/tests/unit/specs/components/post-composer.test.js index 285ab6cd..0a4114a7 100644 --- a/tests/unit/specs/components/post-composer.test.js +++ b/tests/unit/specs/components/post-composer.test.js @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { chooseModal, makeTestDataLayer, + makeTestPluginService, respondToConfirm, stubRecordLinkResolution, waitFor, @@ -39,14 +40,7 @@ describe("post-composer", () => { displayName: "Test User", avatar: null, }; - element.pluginService = { - $richTextTransformsVersion: { get: () => 0 }, - transformRichTextTokens: async () => null, - renderRichTextNodeToken: () => null, - getClaimedFacetTypes: () => new Set(), - $slots: { get: () => null }, - getSlotEntries: () => [], - }; + element.pluginService = makeTestPluginService(); return element; } diff --git a/tests/unit/specs/mainLayout.test.js b/tests/unit/specs/mainLayout.test.js index 512d0b04..2fb94614 100644 --- a/tests/unit/specs/mainLayout.test.js +++ b/tests/unit/specs/mainLayout.test.js @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { MainLayout, mainLayoutTemplate } from "/js/mainLayout.js"; import { render, html } from "/js/lib/lit-html.js"; import { Signal, SignalSet } from "/js/signals.js"; +import { makeTestPluginService } from "../testHelpers.js"; const mockUser = { did: "did:plc:testuser", @@ -36,7 +37,9 @@ describe("MainLayout", () => { chatNotificationService: { $numNotifications: $numChatNotifications }, postComposerService: { composePost }, accountSwitcherService: null, - pluginService: { getSidebarItems: () => [...sidebarItems] }, + pluginService: makeTestPluginService({ + getSidebarItems: () => [...sidebarItems], + }), groupChatLinkService: { handleAction: mock.fn() }, interactionHandlers: { postInteractionHandler: {} }, }; @@ -218,9 +221,7 @@ describe("MainLayout", () => { }); }); -const mockPluginService = { - getSidebarItems: () => [], -}; +const mockPluginService = makeTestPluginService(); describe("mainLayoutTemplate", () => { it("should render children in center column", () => { diff --git a/tests/unit/specs/plugins/pluginRichTextDispatcher.test.js b/tests/unit/specs/plugins/pluginRichTextDispatcher.test.js new file mode 100644 index 00000000..9c0d885b --- /dev/null +++ b/tests/unit/specs/plugins/pluginRichTextDispatcher.test.js @@ -0,0 +1,411 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { PluginRichTextDispatcher } from "/js/plugins/pluginRichTextDispatcher.js"; +import { PluginRenderer } from "/js/plugins/pluginRendering.js"; + +// The dispatcher mounts node tokens through the owning plugin's renderer; the +// real PluginRenderer keeps the sanitization path under test. +function makeDispatcher() { + return new PluginRichTextDispatcher({ + getRenderer: (pluginId) => new PluginRenderer(null, pluginId), + }); +} + +describe("PluginRichTextDispatcher - claimed facet types", () => { + it("is empty when no transforms are registered", () => { + assert.deepEqual([...makeDispatcher().getClaimedFacetTypes()], []); + }); + + it("unions handlesFacetTypes across registered transforms", () => { + const dispatcher = makeDispatcher(); + dispatcher.register({ + pluginId: "alpha", + handlesFacetTypes: ["blue.moji.richtext.facet", "com.domain.foo"], + invoke: () => {}, + }); + dispatcher.register({ + pluginId: "beta", + handlesFacetTypes: ["com.domain.foo"], + invoke: () => {}, + }); + assert.deepEqual([...dispatcher.getClaimedFacetTypes()].sort(), [ + "blue.moji.richtext.facet", + "com.domain.foo", + ]); + }); + + it("drops entries when a transform unregisters", () => { + const dispatcher = makeDispatcher(); + const dispose = dispatcher.register({ + pluginId: "alpha", + handlesFacetTypes: ["blue.moji.richtext.facet"], + invoke: () => {}, + }); + dispose(); + assert.deepEqual([...dispatcher.getClaimedFacetTypes()], []); + }); + + it("tolerates a transform registered without handlesFacetTypes", () => { + const dispatcher = makeDispatcher(); + dispatcher.register({ pluginId: "alpha", invoke: () => {} }); + assert.deepEqual([...dispatcher.getClaimedFacetTypes()], []); + }); +}); + +describe("PluginRichTextDispatcher - transform pipeline", () => { + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + + function makeContext({ + uri = "at://did:test/app.bsky.feed.post/1", + surface = "largePost", + text = "hello", + facets = [], + } = {}) { + return { + surface, + uri, + did: "did:test", + numberOfLines: null, + source: { text, facets }, + }; + } + + function addTransform(dispatcher, pluginId, invoke) { + return dispatcher.register({ pluginId, invoke }); + } + + function silencingErrors(run) { + const originalError = console.error; + console.error = () => {}; + return Promise.resolve() + .then(run) + .finally(() => { + console.error = originalError; + }); + } + + it("resolves null with no transforms registered", async () => { + const dispatcher = makeDispatcher(); + const tokens = [{ type: "text", value: "hello" }]; + assert.deepEqual( + await dispatcher.transformTokens(tokens, makeContext()), + null, + ); + }); + + it("resolves the transformed tokens and caches them per post and surface", async () => { + const dispatcher = makeDispatcher(); + const batches = []; + addTransform(dispatcher, "alpha", async (batch) => { + batches.push(batch); + return batch.map(({ tokens }) => ({ + value: [...tokens, { type: "text", value: "!" }], + })); + }); + const tokens = [{ type: "text", value: "hello" }]; + const context = makeContext(); + + const transformed = await dispatcher.transformTokens(tokens, context); + assert.deepEqual(transformed, [ + { type: "text", value: "hello" }, + { type: "text", value: "!" }, + ]); + + // Second request hits the cache: same result, no extra plugin call. + assert.deepEqual( + await dispatcher.transformTokens(tokens, context), + transformed, + ); + assert.deepEqual(batches.length, 1); + }); + + it("batches all posts of a render burst into one call per plugin", async () => { + const dispatcher = makeDispatcher(); + const batches = []; + addTransform(dispatcher, "alpha", async (batch) => { + batches.push(batch); + return batch.map(({ tokens }) => ({ value: tokens })); + }); + + await Promise.all([ + dispatcher.transformTokens( + [{ type: "text", value: "one" }], + makeContext({ uri: "at://post/1", text: "one" }), + ), + dispatcher.transformTokens( + [{ type: "text", value: "two" }], + makeContext({ uri: "at://post/2", text: "two" }), + ), + ]); + + assert.deepEqual(batches.length, 1); + assert.deepEqual(batches[0].length, 2); + assert.deepEqual(batches[0][0].tokens, [{ type: "text", value: "one" }]); + assert.deepEqual(batches[0][1].tokens, [{ type: "text", value: "two" }]); + }); + + it("shares one run between concurrent requests for the same post and surface", async () => { + const dispatcher = makeDispatcher(); + const batches = []; + addTransform(dispatcher, "alpha", async (batch) => { + batches.push(batch); + return batch.map(({ tokens }) => ({ value: tokens })); + }); + const tokens = [{ type: "text", value: "hello" }]; + const context = makeContext(); + + const [first, second] = await Promise.all([ + dispatcher.transformTokens(tokens, context), + dispatcher.transformTokens(tokens, context), + ]); + + assert.deepEqual(first, second); + assert.deepEqual(batches.length, 1); + assert.deepEqual(batches[0].length, 1); + }); + + it("chains transforms in registration order", async () => { + const dispatcher = makeDispatcher(); + addTransform(dispatcher, "alpha", async (batch) => + batch.map(({ tokens }) => ({ + value: [...tokens, { type: "text", value: "A" }], + })), + ); + addTransform(dispatcher, "beta", async (batch) => + batch.map(({ tokens }) => ({ + value: [...tokens, { type: "text", value: "B" }], + })), + ); + + const transformed = await dispatcher.transformTokens( + [{ type: "text", value: "hello" }], + makeContext(), + ); + + assert.deepEqual( + transformed.map((token) => token.value), + ["hello", "A", "B"], + ); + }); + + it("fails open when a transform throws", async () => { + const dispatcher = makeDispatcher(); + addTransform(dispatcher, "alpha", async () => { + throw new Error("boom"); + }); + addTransform(dispatcher, "beta", async (batch) => + batch.map(({ tokens }) => ({ + value: [...tokens, { type: "text", value: "B" }], + })), + ); + + const transformed = await silencingErrors(() => + dispatcher.transformTokens( + [{ type: "text", value: "hello" }], + makeContext(), + ), + ); + + assert.deepEqual( + transformed.map((token) => token.value), + ["hello", "B"], + ); + }); + + it("fails open per item on error entries and malformed tokens", async () => { + const dispatcher = makeDispatcher(); + addTransform(dispatcher, "alpha", async (batch) => + batch.map(({ context }) => + context.uri.endsWith("/1") + ? { error: "no thanks" } + : { value: [{ type: "bogus" }] }, + ), + ); + + const [first, second] = await silencingErrors(() => + Promise.all([ + dispatcher.transformTokens( + [{ type: "text", value: "one" }], + makeContext({ uri: "at://post/1", text: "one" }), + ), + dispatcher.transformTokens( + [{ type: "text", value: "two" }], + makeContext({ uri: "at://post/2", text: "two" }), + ), + ]), + ); + + assert.deepEqual(first, [{ type: "text", value: "one" }]); + assert.deepEqual(second, [{ type: "text", value: "two" }]); + }); + + it("re-hydrates returned facet tokens to the host originals", async () => { + const dispatcher = makeDispatcher(); + const facet = { + index: { byteStart: 0, byteEnd: 4 }, + features: [{ $type: "app.bsky.richtext.facet#tag", tag: "tag" }], + }; + const facetToken = { type: "facet", facet, text: "#tag" }; + // Simulate the structured-clone boundary: the plugin returns a copy. + addTransform(dispatcher, "alpha", async (batch) => + batch.map(({ tokens }) => ({ + value: JSON.parse(JSON.stringify(tokens)), + })), + ); + + const transformed = await dispatcher.transformTokens( + [facetToken, { type: "text", value: " in front" }], + makeContext({ text: "#tag in front", facets: [facet] }), + ); + + assert( + transformed[0] === facetToken, + "facet token should be the host object", + ); + }); + + it("rejects a result containing an unrecognized facet", async () => { + const dispatcher = makeDispatcher(); + addTransform(dispatcher, "alpha", async (batch) => + batch.map(() => ({ + value: [ + { + type: "facet", + facet: { index: { byteStart: 0, byteEnd: 99 }, features: [] }, + text: "forged", + }, + ], + })), + ); + const tokens = [{ type: "text", value: "hello" }]; + + const transformed = await silencingErrors(() => + dispatcher.transformTokens(tokens, makeContext()), + ); + + assert.deepEqual(transformed, tokens); + }); + + it("stamps inline/block tokens with the emitting transform's pluginId and preserves earlier ids", async () => { + const dispatcher = makeDispatcher(); + const node = { tag: "code", text: "x" }; + addTransform(dispatcher, "alpha", async (batch) => + batch.map(() => ({ value: [{ type: "inline", node }] })), + ); + addTransform(dispatcher, "beta", async (batch) => + batch.map(({ tokens }) => ({ + value: [...tokens, { type: "block", node }], + })), + ); + + const transformed = await dispatcher.transformTokens( + [{ type: "text", value: "hello" }], + makeContext(), + ); + + assert.deepEqual( + transformed.map((token) => token.pluginId), + ["alpha", "beta"], + ); + }); + + it("re-stamps a forged pluginId naming another plugin", async () => { + const dispatcher = makeDispatcher(); + const node = { tag: "code", text: "x" }; + addTransform(dispatcher, "alpha", async (batch) => + batch.map(() => ({ + value: [{ type: "inline", pluginId: "victim", node }], + })), + ); + + const transformed = await dispatcher.transformTokens( + [{ type: "text", value: "hello" }], + makeContext(), + ); + + assert.deepEqual( + transformed.map((token) => token.pluginId), + ["alpha"], + ); + }); + + it("clears cached results when the transform set changes", async () => { + const dispatcher = makeDispatcher(); + const batches = []; + addTransform(dispatcher, "alpha", async (batch) => { + batches.push(batch); + return batch.map(({ tokens }) => ({ value: tokens })); + }); + const tokens = [{ type: "text", value: "hello" }]; + const context = makeContext(); + + await dispatcher.transformTokens(tokens, context); + dispatcher._invalidate(); + await dispatcher.transformTokens(tokens, context); + + assert.deepEqual(batches.length, 2); + }); + + it("resolves in-flight requests with null when transforms change mid-run", async () => { + const dispatcher = makeDispatcher(); + let releaseTransform; + const gate = new Promise((resolve) => { + releaseTransform = resolve; + }); + addTransform(dispatcher, "alpha", async (batch) => { + await gate; + return batch.map(({ tokens }) => ({ value: tokens })); + }); + const request = dispatcher.transformTokens( + [{ type: "text", value: "hello" }], + makeContext(), + ); + await flush(); + dispatcher._invalidate(); + releaseTransform(); + + assert.deepEqual(await request, null); + assert.deepEqual(dispatcher._cache.size, 0); + }); + + it("re-runs when the cached entry no longer matches the source text", async () => { + const dispatcher = makeDispatcher(); + const batches = []; + addTransform(dispatcher, "alpha", async (batch) => { + batches.push(batch); + return batch.map(({ tokens }) => ({ value: tokens })); + }); + const context = makeContext({ text: "before" }); + + await dispatcher.transformTokens( + [{ type: "text", value: "before" }], + context, + ); + const transformed = await dispatcher.transformTokens( + [{ type: "text", value: "after" }], + makeContext({ text: "after" }), + ); + + assert.deepEqual(batches.length, 2); + assert.deepEqual(transformed, [{ type: "text", value: "after" }]); + }); + + it("renderRichTextNodeToken mounts a sanitized element and reuses it per token and host", () => { + const dispatcher = makeDispatcher(); + const token = { + type: "inline", + pluginId: "alpha", + node: { tag: "code", attrs: {}, text: "x", children: [], events: {} }, + }; + const host = document.createElement("div"); + + const element = dispatcher.renderNodeToken(token, host); + assert.deepEqual(element.localName, "code"); + assert.deepEqual(element.textContent, "x"); + assert(dispatcher.renderNodeToken(token, host) === element); + const otherHost = document.createElement("div"); + const otherElement = dispatcher.renderNodeToken(token, otherHost); + assert.deepEqual(otherElement.localName, "code"); + assert(otherElement !== element); + }); +}); diff --git a/tests/unit/specs/plugins/pluginService.test.js b/tests/unit/specs/plugins/pluginService.test.js index dcabb7c5..1af835d5 100644 --- a/tests/unit/specs/plugins/pluginService.test.js +++ b/tests/unit/specs/plugins/pluginService.test.js @@ -1,4 +1,4 @@ -import { describe, it, afterEach } from "node:test"; +import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { PluginService, @@ -1290,7 +1290,9 @@ describe("feed filter integration", () => { }); }); -describe("getClaimedFacetTypes", () => { +// The dispatcher's own behavior is covered in pluginRichTextDispatcher.test.js; +// these cover the bridge wiring and the facade rich-text elements read through. +describe("rich text wiring", () => { function makeServiceWithRealBridge() { const { provider } = makeProvider(); return new PluginService( @@ -1300,194 +1302,88 @@ describe("getClaimedFacetTypes", () => { new HiddenFeedItemsStore(), ); } - function registerTransform(service, pluginId, message) { - const handler = - service.pluginBridge._registrationTargets.get("richTextTransform"); - return handler({ pluginId, call: () => {} }, message); - } - - it("is empty when no transforms are registered", () => { - const service = makeServiceWithRealBridge(); - assert.deepEqual([...service.getClaimedFacetTypes()], []); - }); - - it("unions handlesFacetTypes across registered transforms", () => { - const service = makeServiceWithRealBridge(); - registerTransform(service, "alpha", { - handlerId: 1, - handlesFacetTypes: ["blue.moji.richtext.facet", "com.domain.foo"], - }); - registerTransform(service, "beta", { - handlerId: 2, - handlesFacetTypes: ["com.domain.foo"], - }); - assert.deepEqual([...service.getClaimedFacetTypes()].sort(), [ - "blue.moji.richtext.facet", - "com.domain.foo", - ]); - }); - - it("drops entries when a transform unregisters", () => { - const service = makeServiceWithRealBridge(); - const dispose = registerTransform(service, "alpha", { - handlerId: 1, - handlesFacetTypes: ["blue.moji.richtext.facet"], - }); - dispose(); - assert.deepEqual([...service.getClaimedFacetTypes()], []); - }); - it("tolerates a transform registered without handlesFacetTypes", () => { - const service = makeServiceWithRealBridge(); - registerTransform(service, "alpha", { handlerId: 1 }); - assert.deepEqual([...service.getClaimedFacetTypes()], []); - }); -}); - -describe("slot registry", () => { - // These tests exercise the registration target wired by _setupRegistries, - // so they need the real PluginBridge instead of the makeService stub. - function makeServiceWithRealBridge() { - const { provider } = makeProvider(); - return new PluginService( - provider, - null, - emptyDataLayer(), - new HiddenFeedItemsStore(), + function registerTransform(service, plugin, message) { + return service.pluginBridge._registrationTargets.get("richTextTransform")( + plugin, + { target: "richTextTransform", handlerId: 3, ...message }, ); } - function register(service, plugin, message) { - const handler = service.pluginBridge._registrationTargets.get("slot"); - return handler(plugin, message); - } - - function makePlugin(pluginId, calls = []) { - return { - pluginId, - call: (handlerId, ...args) => { - calls.push({ handlerId, args }); - return Promise.resolve({ tag: "div", attrs: {}, text: pluginId }); - }, - }; - } - - it("returns an empty list for unknown slots", () => { - const service = makeServiceWithRealBridge(); - assert.deepEqual(service.getSlotEntries("nope"), []); - }); - - it("records registrations in order", async () => { + it("registers the plugin's transform with the dispatcher", async () => { const service = makeServiceWithRealBridge(); - register(service, makePlugin("alpha"), { - target: "slot", - name: "x", - handlerId: 1, - }); - register(service, makePlugin("beta"), { - target: "slot", - name: "x", - handlerId: 2, - }); - const entries = service.getSlotEntries("x"); + const calls = []; + registerTransform( + service, + { + pluginId: "alpha", + call: (handlerId, batch) => { + calls.push({ handlerId, batch }); + return Promise.resolve( + batch.map(({ tokens }) => ({ value: tokens })), + ); + }, + }, + { handlesFacetTypes: ["blue.moji.richtext.facet"] }, + ); assert.deepEqual( - entries.map((entry) => entry.pluginId), - ["alpha", "beta"], + [...service.getClaimedFacetTypes()], + ["blue.moji.richtext.facet"], ); - }); - it("invokes the plugin handler with the slot context", async () => { - const service = makeServiceWithRealBridge(); - const calls = []; - register(service, makePlugin("alpha", calls), { - target: "slot", - name: "x", - handlerId: 7, - }); - const [entry] = service.getSlotEntries("x"); - await entry.invoke({ uri: "at://test" }); - 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, + const tokens = [{ type: "text", value: "hello" }]; + await service.transformRichTextTokens(tokens, { + surface: "largePost", + uri: "at://did:test/app.bsky.feed.post/1", + did: "did:test", + numberOfLines: null, + source: { text: "hello", facets: [] }, }); - assert.notEqual(service.getSlotEntries("x")[0].version, firstVersion); + assert.deepEqual(calls.length, 1); + assert.deepEqual(calls[0].handlerId, 3); + assert.deepEqual(calls[0].batch[0].tokens, tokens); }); - it("warns and skips when a plugin registers the same slot twice", () => { + it("exposes the dispatcher's version signal, which a registration bumps", () => { 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"), { - target: "slot", - name: "x", - handlerId: 1, + assert.equal( + service.$richTextTransformsVersion, + service.richTextDispatcher.$version, + ); + const versionBefore = service.$richTextTransformsVersion.get(); + const dispose = registerTransform(service, { + pluginId: "alpha", + call: () => {}, }); - assert.deepEqual(service.getSlotEntries("x").length, 1); + assert.notEqual(service.$richTextTransformsVersion.get(), versionBefore); + const versionAfterRegister = service.$richTextTransformsVersion.get(); dispose(); - assert.deepEqual(service.getSlotEntries("x"), []); - assert.deepEqual(service.$slots.get("x"), null); + assert.notEqual( + service.$richTextTransformsVersion.get(), + versionAfterRegister, + ); + assert.deepEqual([...service.getClaimedFacetTypes()], []); }); - it("updates the $slots signal on register and unregister", () => { + it("mounts node tokens through the emitting plugin's renderer", () => { const service = makeServiceWithRealBridge(); - const updates = []; - const initial = service.$slots.get("x"); - const dispose = register(service, makePlugin("alpha"), { - target: "slot", - name: "x", - handlerId: 1, - }); - updates.push( - service.$slots.get("x")?.map((entry) => entry.pluginId) ?? null, - ); - dispose(); - updates.push( - service.$slots.get("x")?.map((entry) => entry.pluginId) ?? null, + const host = document.createElement("div"); + const element = service.renderRichTextNodeToken( + { + type: "inline", + pluginId: "alpha", + node: { tag: "code", attrs: {}, text: "x", children: [], events: {} }, + }, + host, ); - assert.deepEqual(initial, null); - assert.deepEqual(updates, [["alpha"], null]); + assert.deepEqual(element.localName, "code"); + assert.deepEqual(element.textContent, "x"); }); }); -describe("refreshSlot host method", () => { +// The dispatcher's own behavior is covered in pluginSlotDispatcher.test.js; these +// cover the bridge wiring and the facade the slot element reads through. +describe("slot wiring", () => { function makeServiceWithRealBridge() { const { provider } = makeProvider(); return new PluginService( @@ -1498,102 +1394,70 @@ describe("refreshSlot host method", () => { ); } - 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); + function registerSlot(service, plugin, message = {}) { + return service.pluginBridge._registrationTargets.get("slot")(plugin, { + target: "slot", + name: "author-badges", + handlerId: 7, + ...message, + }); } - it("bumps only the calling plugin's entry versions and re-sets the list", () => { + it("registers the plugin's slot handler with the dispatcher", async () => { const service = makeServiceWithRealBridge(); - register( - service, - { pluginId: "alpha", call: () => {} }, - { - target: "slot", - name: "author-badges", - handlerId: 1, - }, - ); - register( + const calls = []; + registerSlot( service, - { pluginId: "beta", call: () => {} }, { - target: "slot", - name: "author-badges", - handlerId: 1, + pluginId: "alpha", + call: (handlerId, payload) => { + calls.push({ handlerId, payload }); + return Promise.resolve([{ value: null }]); + }, }, + { cacheKey: ["did"], batch: true }, ); - const before = service.$slots.get("author-badges"); - const [alphaVersionBefore, betaVersionBefore] = before.map( - (entry) => entry.version, - ); - 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), - ["alpha", "beta"], - ); - assert.notEqual(after[0].version, alphaVersionBefore); - assert.equal(after[1].version, betaVersionBefore); + const [registration] = + service.slotDispatcher.getRegistrations("author-badges"); + assert.deepEqual(registration.pluginId, "alpha"); + assert.deepEqual(registration.cacheKey, ["did"]); + + await registration.request({ did: "did:one", uri: "at://a" }); + // The message's batch flag reached the dispatcher (payloads arrive as an + // array), and only the declared cacheKey fields reach the plugin + assert.deepEqual(calls, [{ handlerId: 7, payload: [{ did: "did:one" }] }]); }); - it("dispose still removes the entry after a refresh replaced it", () => { + it("exposes the dispatcher's registrations and slot signal", () => { 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" }, - ); + const dispose = registerSlot(service, { + pluginId: "alpha", + call: () => Promise.resolve([]), + }); + assert.deepEqual(service.getSlotRegistrations("author-badges").length, 1); + assert.equal(service.$slots, service.slotDispatcher.$slots); + assert.deepEqual(service.$slots.get("author-badges").length, 1); dispose(); - assert.deepEqual(service.$slots.get("author-badges"), null); + assert.deepEqual(service.getSlotRegistrations("author-badges"), []); }); - it("is a no-op for a slot name nobody has registered", () => { + it("routes the refreshSlot host method to the calling plugin's slot", async () => { const service = makeServiceWithRealBridge(); - assert.doesNotThrow(() => - getHandler(service, "refreshSlot")( - { pluginId: "alpha" }, - { name: "nope" }, - ), - ); - assert.deepEqual(service.$slots.get("nope"), null); - }); + registerSlot(service, { + pluginId: "alpha", + call: () => Promise.resolve([{ value: null }]), + }); + const [registration] = + service.slotDispatcher.getRegistrations("author-badges"); + const context = { did: "did:one" }; + await registration.request(context); + const versionBefore = registration.versionFor(context); - 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" }, + service.pluginBridge._hostCallHandlers.get("refreshSlot")( + { pluginId: "alpha" }, + { name: "author-badges", keys: [context] }, ); - assert.equal(service.$slots.get("author-badges"), before); - assert.equal(before[0].version, versionBefore); + assert.notEqual(registration.versionFor(context), versionBefore); }); }); @@ -2258,363 +2122,3 @@ describe("getPostComposerInit", () => { }); }); }); - -describe("rich text transform pipeline", () => { - const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - - function makeContext({ - uri = "at://did:test/app.bsky.feed.post/1", - surface = "largePost", - text = "hello", - facets = [], - } = {}) { - return { - surface, - uri, - did: "did:test", - numberOfLines: null, - source: { text, facets }, - }; - } - - function addTransform(service, pluginId, invoke) { - const entry = { pluginId, invoke }; - service.registries.richTextTransforms.add(entry); - return entry; - } - - function silencingErrors(run) { - const originalError = console.error; - console.error = () => {}; - return Promise.resolve() - .then(run) - .finally(() => { - console.error = originalError; - }); - } - - it("resolves null with no transforms registered", async () => { - const { service } = makeService(); - const tokens = [{ type: "text", value: "hello" }]; - assert.deepEqual( - await service.transformRichTextTokens(tokens, makeContext()), - null, - ); - }); - - it("resolves the transformed tokens and caches them per post and surface", async () => { - const { service } = makeService(); - const batches = []; - addTransform(service, "alpha", async (batch) => { - batches.push(batch); - return batch.map(({ tokens }) => ({ - value: [...tokens, { type: "text", value: "!" }], - })); - }); - const tokens = [{ type: "text", value: "hello" }]; - const context = makeContext(); - - const transformed = await service.transformRichTextTokens(tokens, context); - assert.deepEqual(transformed, [ - { type: "text", value: "hello" }, - { type: "text", value: "!" }, - ]); - - // Second request hits the cache: same result, no extra plugin call. - assert.deepEqual( - await service.transformRichTextTokens(tokens, context), - transformed, - ); - assert.deepEqual(batches.length, 1); - }); - - it("batches all posts of a render burst into one call per plugin", async () => { - const { service } = makeService(); - const batches = []; - addTransform(service, "alpha", async (batch) => { - batches.push(batch); - return batch.map(({ tokens }) => ({ value: tokens })); - }); - - await Promise.all([ - service.transformRichTextTokens( - [{ type: "text", value: "one" }], - makeContext({ uri: "at://post/1", text: "one" }), - ), - service.transformRichTextTokens( - [{ type: "text", value: "two" }], - makeContext({ uri: "at://post/2", text: "two" }), - ), - ]); - - assert.deepEqual(batches.length, 1); - assert.deepEqual(batches[0].length, 2); - assert.deepEqual(batches[0][0].tokens, [{ type: "text", value: "one" }]); - assert.deepEqual(batches[0][1].tokens, [{ type: "text", value: "two" }]); - }); - - it("shares one run between concurrent requests for the same post and surface", async () => { - const { service } = makeService(); - const batches = []; - addTransform(service, "alpha", async (batch) => { - batches.push(batch); - return batch.map(({ tokens }) => ({ value: tokens })); - }); - const tokens = [{ type: "text", value: "hello" }]; - const context = makeContext(); - - const [first, second] = await Promise.all([ - service.transformRichTextTokens(tokens, context), - service.transformRichTextTokens(tokens, context), - ]); - - assert.deepEqual(first, second); - assert.deepEqual(batches.length, 1); - assert.deepEqual(batches[0].length, 1); - }); - - it("chains transforms in registration order", async () => { - const { service } = makeService(); - addTransform(service, "alpha", async (batch) => - batch.map(({ tokens }) => ({ - value: [...tokens, { type: "text", value: "A" }], - })), - ); - addTransform(service, "beta", async (batch) => - batch.map(({ tokens }) => ({ - value: [...tokens, { type: "text", value: "B" }], - })), - ); - - const transformed = await service.transformRichTextTokens( - [{ type: "text", value: "hello" }], - makeContext(), - ); - - assert.deepEqual( - transformed.map((token) => token.value), - ["hello", "A", "B"], - ); - }); - - it("fails open when a transform throws", async () => { - const { service } = makeService(); - addTransform(service, "alpha", async () => { - throw new Error("boom"); - }); - addTransform(service, "beta", async (batch) => - batch.map(({ tokens }) => ({ - value: [...tokens, { type: "text", value: "B" }], - })), - ); - - const transformed = await silencingErrors(() => - service.transformRichTextTokens( - [{ type: "text", value: "hello" }], - makeContext(), - ), - ); - - assert.deepEqual( - transformed.map((token) => token.value), - ["hello", "B"], - ); - }); - - it("fails open per item on error entries and malformed tokens", async () => { - const { service } = makeService(); - addTransform(service, "alpha", async (batch) => - batch.map(({ context }) => - context.uri.endsWith("/1") - ? { error: "no thanks" } - : { value: [{ type: "bogus" }] }, - ), - ); - - const [first, second] = await silencingErrors(() => - Promise.all([ - service.transformRichTextTokens( - [{ type: "text", value: "one" }], - makeContext({ uri: "at://post/1", text: "one" }), - ), - service.transformRichTextTokens( - [{ type: "text", value: "two" }], - makeContext({ uri: "at://post/2", text: "two" }), - ), - ]), - ); - - assert.deepEqual(first, [{ type: "text", value: "one" }]); - assert.deepEqual(second, [{ type: "text", value: "two" }]); - }); - - it("re-hydrates returned facet tokens to the host originals", async () => { - const { service } = makeService(); - const facet = { - index: { byteStart: 0, byteEnd: 4 }, - features: [{ $type: "app.bsky.richtext.facet#tag", tag: "tag" }], - }; - const facetToken = { type: "facet", facet, text: "#tag" }; - // Simulate the structured-clone boundary: the plugin returns a copy. - addTransform(service, "alpha", async (batch) => - batch.map(({ tokens }) => ({ - value: JSON.parse(JSON.stringify(tokens)), - })), - ); - - const transformed = await service.transformRichTextTokens( - [facetToken, { type: "text", value: " in front" }], - makeContext({ text: "#tag in front", facets: [facet] }), - ); - - assert( - transformed[0] === facetToken, - "facet token should be the host object", - ); - }); - - it("rejects a result containing an unrecognized facet", async () => { - const { service } = makeService(); - addTransform(service, "alpha", async (batch) => - batch.map(() => ({ - value: [ - { - type: "facet", - facet: { index: { byteStart: 0, byteEnd: 99 }, features: [] }, - text: "forged", - }, - ], - })), - ); - const tokens = [{ type: "text", value: "hello" }]; - - const transformed = await silencingErrors(() => - service.transformRichTextTokens(tokens, makeContext()), - ); - - assert.deepEqual(transformed, tokens); - }); - - it("stamps inline/block tokens with the emitting transform's pluginId and preserves earlier ids", async () => { - const { service } = makeService(); - const node = { tag: "code", text: "x" }; - addTransform(service, "alpha", async (batch) => - batch.map(() => ({ value: [{ type: "inline", node }] })), - ); - addTransform(service, "beta", async (batch) => - batch.map(({ tokens }) => ({ - value: [...tokens, { type: "block", node }], - })), - ); - - const transformed = await service.transformRichTextTokens( - [{ type: "text", value: "hello" }], - makeContext(), - ); - - assert.deepEqual( - transformed.map((token) => token.pluginId), - ["alpha", "beta"], - ); - }); - - it("re-stamps a forged pluginId naming another plugin", async () => { - const { service } = makeService(); - const node = { tag: "code", text: "x" }; - addTransform(service, "alpha", async (batch) => - batch.map(() => ({ - value: [{ type: "inline", pluginId: "victim", node }], - })), - ); - - const transformed = await service.transformRichTextTokens( - [{ type: "text", value: "hello" }], - makeContext(), - ); - - assert.deepEqual( - transformed.map((token) => token.pluginId), - ["alpha"], - ); - }); - - it("clears cached results when the transform set changes", async () => { - const { service } = makeService(); - const batches = []; - addTransform(service, "alpha", async (batch) => { - batches.push(batch); - return batch.map(({ tokens }) => ({ value: tokens })); - }); - const tokens = [{ type: "text", value: "hello" }]; - const context = makeContext(); - - await service.transformRichTextTokens(tokens, context); - service._invalidateRichTextTransforms(); - await service.transformRichTextTokens(tokens, context); - - assert.deepEqual(batches.length, 2); - }); - - it("resolves in-flight requests with null when transforms change mid-run", async () => { - const { service } = makeService(); - let releaseTransform; - const gate = new Promise((resolve) => { - releaseTransform = resolve; - }); - addTransform(service, "alpha", async (batch) => { - await gate; - return batch.map(({ tokens }) => ({ value: tokens })); - }); - const request = service.transformRichTextTokens( - [{ type: "text", value: "hello" }], - makeContext(), - ); - await flush(); - service._invalidateRichTextTransforms(); - releaseTransform(); - - assert.deepEqual(await request, null); - assert.deepEqual(service._richTextTokensCache.size, 0); - }); - - it("re-runs when the cached entry no longer matches the source text", async () => { - const { service } = makeService(); - const batches = []; - addTransform(service, "alpha", async (batch) => { - batches.push(batch); - return batch.map(({ tokens }) => ({ value: tokens })); - }); - const context = makeContext({ text: "before" }); - - await service.transformRichTextTokens( - [{ type: "text", value: "before" }], - context, - ); - const transformed = await service.transformRichTextTokens( - [{ type: "text", value: "after" }], - makeContext({ text: "after" }), - ); - - assert.deepEqual(batches.length, 2); - assert.deepEqual(transformed, [{ type: "text", value: "after" }]); - }); - - it("renderRichTextNodeToken mounts a sanitized element and reuses it per token and host", () => { - const { service } = makeService(); - const token = { - type: "inline", - pluginId: "alpha", - node: { tag: "code", attrs: {}, text: "x", children: [], events: {} }, - }; - const host = document.createElement("div"); - - const element = service.renderRichTextNodeToken(token, host); - assert.deepEqual(element.localName, "code"); - assert.deepEqual(element.textContent, "x"); - assert(service.renderRichTextNodeToken(token, host) === element); - const otherHost = document.createElement("div"); - const otherElement = service.renderRichTextNodeToken(token, otherHost); - assert.deepEqual(otherElement.localName, "code"); - assert(otherElement !== element); - }); -}); diff --git a/tests/unit/specs/plugins/pluginSlotDispatcher.test.js b/tests/unit/specs/plugins/pluginSlotDispatcher.test.js new file mode 100644 index 00000000..321eabb6 --- /dev/null +++ b/tests/unit/specs/plugins/pluginSlotDispatcher.test.js @@ -0,0 +1,650 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { isPromise } from "/js/utils.js"; +import { + PluginSlotDispatcher, + SlotInvocationMonitor, +} from "/js/plugins/pluginSlotDispatcher.js"; + +const SLOT = "post:badges"; + +// Mirrors the SDK's batch wrapper: one call per flush, an array of +// { value } | { error } results in payload order. `calls` records the payload +// of every call so tests can assert on batching and dedupe. +function makeInvoke({ batch = true } = {}) { + const calls = []; + const invoke = (payload) => { + calls.push(payload); + if (!batch) { + return Promise.resolve({ tag: "div", text: JSON.stringify(payload) }); + } + return Promise.resolve( + payload.map((context) => ({ + value: { tag: "div", text: JSON.stringify(context) }, + })), + ); + }; + return { invoke, calls }; +} + +function register( + dispatcher, + { pluginId = "alpha", name = SLOT, ...rest } = {}, +) { + const { invoke, calls } = makeInvoke(rest); + const dispose = dispatcher.register({ + pluginId, + name, + batch: true, + invoke, + ...rest, + }); + const registration = dispatcher + .getRegistrations(name) + .find((candidate) => candidate.pluginId === pluginId); + return { registration, dispose, calls }; +} + +describe("PluginSlotDispatcher - registry", () => { + it("returns an empty list for unknown slots", () => { + const dispatcher = new PluginSlotDispatcher(); + assert.deepEqual(dispatcher.getRegistrations("nope"), []); + }); + + it("records registrations in order", () => { + const dispatcher = new PluginSlotDispatcher(); + register(dispatcher, { pluginId: "alpha", name: "x" }); + register(dispatcher, { pluginId: "beta", name: "x" }); + assert.deepEqual( + dispatcher.getRegistrations("x").map((entry) => entry.pluginId), + ["alpha", "beta"], + ); + }); + + it("keeps only the declared string fields of a cacheKey", () => { + const dispatcher = new PluginSlotDispatcher(); + const { registration } = register(dispatcher, { + cacheKey: ["did", 7, "uri"], + }); + assert.deepEqual(registration.cacheKey, ["did", "uri"]); + }); + + it("treats an absent cacheKey as no cacheKey", () => { + const dispatcher = new PluginSlotDispatcher(); + assert.deepEqual(register(dispatcher).registration.cacheKey, null); + }); + + it("keeps an empty cacheKey as a declaration of its own", () => { + const dispatcher = new PluginSlotDispatcher(); + const { registration } = register(dispatcher, { cacheKey: [] }); + assert.deepEqual(registration.cacheKey, []); + }); + + it("keys a cacheKey registration's context on the declared fields only", () => { + const dispatcher = new PluginSlotDispatcher(); + const { registration } = register(dispatcher, { cacheKey: ["did"] }); + const key = registration.contextKeyFor({ uri: "at://a", did: "did:one" }); + assert.equal(registration.contextKeyFor({ did: "did:one" }), key); + assert.equal( + registration.contextKeyFor({ uri: "at://b", did: "did:one" }), + key, + ); + assert.notEqual(registration.contextKeyFor({ did: "did:two" }), key); + }); + + it("keys a registration with no cacheKey on the whole context, field order aside", () => { + const dispatcher = new PluginSlotDispatcher(); + const { registration } = register(dispatcher); + const key = registration.contextKeyFor({ uri: "at://a", did: "did:one" }); + assert.equal( + registration.contextKeyFor({ did: "did:one", uri: "at://a" }), + key, + ); + assert.notEqual(registration.contextKeyFor({ uri: "at://a" }), key); + }); + + it("never reuses a version number for a later registration", () => { + const dispatcher = new PluginSlotDispatcher(); + const { registration, dispose } = register(dispatcher, { name: "x" }); + const firstVersion = registration.versionFor({ did: "did:one" }); + dispose(); + const { registration: second } = register(dispatcher, { name: "x" }); + assert.notEqual(second.versionFor({ did: "did:one" }), firstVersion); + }); + + it("warns and skips when a plugin registers the same slot twice", () => { + const dispatcher = new PluginSlotDispatcher(); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + register(dispatcher, { name: "x" }); + const { dispose } = register(dispatcher, { name: "x" }); + assert.deepEqual(dispose, null); + assert.deepEqual(warnings.length, 1); + assert(warnings[0].includes("alpha")); + assert.deepEqual(dispatcher.getRegistrations("x").length, 1); + } finally { + console.warn = originalWarn; + } + }); + + it("dispose removes the registration and prunes the slot when empty", () => { + const dispatcher = new PluginSlotDispatcher(); + const { dispose } = register(dispatcher, { name: "x" }); + assert.deepEqual(dispatcher.getRegistrations("x").length, 1); + dispose(); + assert.deepEqual(dispatcher.getRegistrations("x"), []); + assert.deepEqual(dispatcher.$slots.get("x"), null); + }); + + it("updates the $slots signal on register and unregister", () => { + const dispatcher = new PluginSlotDispatcher(); + assert.deepEqual(dispatcher.$slots.get("x"), null); + const { dispose } = register(dispatcher, { name: "x" }); + assert.deepEqual( + dispatcher.$slots.get("x").map((entry) => entry.pluginId), + ["alpha"], + ); + dispose(); + assert.deepEqual(dispatcher.$slots.get("x"), null); + }); +}); + +describe("PluginSlotDispatcher - refresh", () => { + it("bumps only the calling plugin's versions and re-emits the list", () => { + const dispatcher = new PluginSlotDispatcher(); + const alpha = register(dispatcher, { pluginId: "alpha" }).registration; + const beta = register(dispatcher, { pluginId: "beta" }).registration; + const context = { did: "did:one" }; + const before = dispatcher.$slots.get(SLOT); + const alphaVersionBefore = alpha.versionFor(context); + const betaVersionBefore = beta.versionFor(context); + + dispatcher.refresh("alpha", SLOT); + + // Slots re-reconcile off the signal; the versions decide who re-invokes + assert.notEqual(dispatcher.$slots.get(SLOT), before); + assert.deepEqual( + dispatcher.$slots.get(SLOT).map((registration) => registration.pluginId), + ["alpha", "beta"], + ); + assert.notEqual(alpha.versionFor(context), alphaVersionBefore); + assert.equal(beta.versionFor(context), betaVersionBefore); + }); + + it("bumps only the contexts a keyed refresh matches", async () => { + const dispatcher = new PluginSlotDispatcher(); + const { registration } = register(dispatcher); + const matching = { uri: "at://a", did: "did:one" }; + const other = { uri: "at://b", did: "did:two" }; + await registration.request(matching); + await registration.request(other); + const matchingBefore = registration.versionFor(matching); + const otherBefore = registration.versionFor(other); + + dispatcher.refresh("alpha", SLOT, [{ did: "did:one" }]); + + assert.notEqual(registration.versionFor(matching), matchingBefore); + assert.equal(registration.versionFor(other), otherBefore); + }); + + it("tracks a context the first time its version is read", () => { + const dispatcher = new PluginSlotDispatcher(); + const { registration } = register(dispatcher); + // Reading a version is how a slot element takes a context into use, so it + // is enough to make that context targetable + const context = { uri: "at://a" }; + const before = registration.versionFor(context); + dispatcher.refresh("alpha", SLOT, [{ uri: "at://a" }]); + assert.notEqual(registration.versionFor(context), before); + }); + + it("bumps nothing for a key no context has matched", () => { + const dispatcher = new PluginSlotDispatcher(); + const { registration } = register(dispatcher); + const context = { uri: "at://a" }; + const before = registration.versionFor(context); + dispatcher.refresh("alpha", SLOT, [{ uri: "at://never-seen" }]); + assert.equal(registration.versionFor(context), before); + }); + + it("bumps every context when a keyed refresh follows forgotten contexts", () => { + const dispatcher = new PluginSlotDispatcher(); + const { registration } = register(dispatcher); + const first = { uri: "at://0" }; + const firstBefore = registration.versionFor(first); + // Overflow the tracked-context cap so `first` is forgotten + for (let index = 0; index < 700; index++) { + registration.versionFor({ uri: `at://x${index}` }); + } + dispatcher.refresh("alpha", SLOT, [{ uri: "at://nothing-tracked" }]); + // Targeting can't be trusted after an eviction, so everything re-invokes + assert.notEqual(registration.versionFor(first), firstBefore); + }); + + it("dispose still removes the registration after a refresh replaced it", () => { + const dispatcher = new PluginSlotDispatcher(); + const { dispose } = register(dispatcher); + dispatcher.refresh("alpha", SLOT); + dispose(); + assert.deepEqual(dispatcher.$slots.get(SLOT), null); + }); + + it("is a no-op for a slot name nobody has registered", () => { + const dispatcher = new PluginSlotDispatcher(); + assert.doesNotThrow(() => dispatcher.refresh("alpha", "nope")); + assert.deepEqual(dispatcher.$slots.get("nope"), null); + }); + + it("is a no-op when the calling plugin has no registration in the slot", () => { + const dispatcher = new PluginSlotDispatcher(); + register(dispatcher, { pluginId: "alpha" }); + const before = dispatcher.$slots.get(SLOT); + dispatcher.refresh("beta", SLOT); + assert.equal(dispatcher.$slots.get(SLOT), before); + }); +}); + +describe("PluginSlotDispatcher - invocation and caching", () => { + // The monitor is exercised separately; leaving it out keeps these tests from + // tripping its dev warnings on the high-volume cases. + function makeDispatcher() { + return new PluginSlotDispatcher({ monitor: null }); + } + + it("batches a flush into one call per plugin", async () => { + const dispatcher = makeDispatcher(); + const { registration, calls } = register(dispatcher); + await Promise.all( + [{ uri: "at://a" }, { uri: "at://b" }].map((context) => + registration.request(context), + ), + ); + assert.deepEqual(calls, [[{ uri: "at://a" }, { uri: "at://b" }]]); + }); + + it("falls back to per-item calls for plugins that don't advertise batching", async () => { + const dispatcher = makeDispatcher(); + const { registration, calls } = register(dispatcher, { batch: false }); + const node = await registration.request({ uri: "at://a" }); + assert.deepEqual(calls, [{ uri: "at://a" }]); + assert.deepEqual(node.text, JSON.stringify({ uri: "at://a" })); + }); + + it("passes the full context to registrations without a cacheKey", async () => { + const dispatcher = makeDispatcher(); + const { registration, calls } = register(dispatcher); + await registration.request({ uri: "at://a", did: "did:one" }); + await registration.request({ uri: "at://a", did: "did:one" }); + assert.deepEqual(calls, [ + [{ uri: "at://a", did: "did:one" }], + [{ uri: "at://a", did: "did:one" }], + ]); + }); + + it("invokes a cacheKey registration once per distinct projection", async () => { + const dispatcher = makeDispatcher(); + const { registration, calls } = register(dispatcher, { cacheKey: ["did"] }); + const requests = [ + registration.request({ uri: "at://a", did: "did:one" }), + registration.request({ uri: "at://b", did: "did:one" }), + registration.request({ uri: "at://c", did: "did:two" }), + ]; + assert.deepEqual(requests.map(isPromise), [true, true, true]); + const nodes = await Promise.all(requests); + assert.deepEqual(calls, [[{ did: "did:one" }, { did: "did:two" }]]); + assert.equal(nodes[0], nodes[1]); + }); + + it("invokes an empty-cacheKey registration once for every context", async () => { + const dispatcher = makeDispatcher(); + const { registration, calls } = register(dispatcher, { cacheKey: [] }); + const requests = [ + registration.request({ uri: "at://a", did: "did:one" }), + registration.request({ uri: "at://b", did: "did:two" }), + ]; + const nodes = await Promise.all(requests); + // The callback is handed nothing, since its output may depend on nothing + assert.deepEqual(calls, [[{}]]); + assert.equal(nodes[0], nodes[1]); + assert.deepEqual( + isPromise(registration.request({ uri: "at://c", did: "did:three" })), + false, + ); + }); + + it("re-invokes an empty-cacheKey registration after a keyless refresh", async () => { + const dispatcher = makeDispatcher(); + const { registration, calls } = register(dispatcher, { cacheKey: [] }); + await registration.request({ did: "did:one" }); + dispatcher.refresh("alpha", SLOT); + const second = registration.request({ did: "did:one" }); + assert(isPromise(second)); + await second; + assert.deepEqual(calls.length, 2); + }); + + it("rejects keys for an empty-cacheKey registration", async () => { + const dispatcher = makeDispatcher(); + const { registration } = register(dispatcher, { cacheKey: [] }); + await registration.request({ did: "did:one" }); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + dispatcher.refresh("alpha", SLOT, [{ did: "did:one" }]); + assert.deepEqual(warnings.length, 1); + assert(warnings[0].includes("empty cacheKey")); + assert.deepEqual( + isPromise(registration.request({ did: "did:one" })), + false, + ); + } finally { + console.warn = originalWarn; + } + }); + + it("serves a later appearance of a known projection synchronously", async () => { + const dispatcher = makeDispatcher(); + const { registration, calls } = register(dispatcher, { cacheKey: ["did"] }); + const node = await registration.request({ uri: "at://a", did: "did:one" }); + const second = registration.request({ uri: "at://z", did: "did:one" }); + assert.deepEqual(isPromise(second), false); + assert.equal(second, node); + assert.deepEqual(calls.length, 1); + }); + + it("keeps caches separate per plugin", async () => { + const dispatcher = makeDispatcher(); + const alpha = register(dispatcher, { + pluginId: "alpha", + cacheKey: ["did"], + }); + const beta = register(dispatcher, { pluginId: "beta", cacheKey: ["did"] }); + await alpha.registration.request({ did: "did:one" }); + await beta.registration.request({ did: "did:one" }); + dispatcher.refresh("alpha", SLOT); + assert(isPromise(alpha.registration.request({ did: "did:one" }))); + assert.deepEqual( + isPromise(beta.registration.request({ did: "did:one" })), + false, + ); + }); + + it("drops the whole cache on a keyless refresh", async () => { + const dispatcher = makeDispatcher(); + const { registration } = register(dispatcher, { cacheKey: ["did"] }); + await registration.request({ did: "did:one" }); + await registration.request({ did: "did:two" }); + dispatcher.refresh("alpha", SLOT); + assert(isPromise(registration.request({ did: "did:one" }))); + assert(isPromise(registration.request({ did: "did:two" }))); + }); + + it("drops the entries a keyed refresh matches, sharing and all", async () => { + const dispatcher = makeDispatcher(); + const { registration } = register(dispatcher, { cacheKey: ["did"] }); + await registration.request({ uri: "at://a", did: "did:one" }); + await registration.request({ uri: "at://c", did: "did:two" }); + dispatcher.refresh("alpha", SLOT, [{ did: "did:one" }]); + // The dropped entry was shared, so every post by that author re-invokes + assert(isPromise(registration.request({ uri: "at://b", did: "did:one" }))); + assert.deepEqual( + isPromise(registration.request({ uri: "at://c", did: "did:two" })), + false, + ); + }); + + it("matches a keyed refresh on every field of a matcher", async () => { + const dispatcher = makeDispatcher(); + const { registration } = register(dispatcher, { + cacheKey: ["did", "surface"], + }); + const context = { did: "did:one", surface: "feed" }; + await registration.request(context); + dispatcher.refresh("alpha", SLOT, [{ did: "did:one", surface: "profile" }]); + assert.deepEqual(isPromise(registration.request(context)), false); + dispatcher.refresh("alpha", SLOT, [{ did: "did:one", surface: "feed" }]); + assert(isPromise(registration.request(context))); + }); + + it("rejects keys naming fields outside the declared cacheKey", async () => { + const dispatcher = makeDispatcher(); + const { registration } = register(dispatcher, { cacheKey: ["did"] }); + const context = { uri: "at://a", did: "did:one" }; + await registration.request(context); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + // The output can't depend on uri, so matching on it can't mean anything + dispatcher.refresh("alpha", SLOT, [{ uri: "at://a" }]); + assert.deepEqual(warnings.length, 1); + assert(warnings[0].includes("cacheKey (did)")); + assert.deepEqual(isPromise(registration.request(context)), false); + } finally { + console.warn = originalWarn; + } + }); + + it("allows any context field for a registration with no cacheKey", async () => { + const dispatcher = makeDispatcher(); + const { registration } = register(dispatcher); + const context = { uri: "at://a", did: "did:one" }; + const before = registration.versionFor(context); + dispatcher.refresh("alpha", SLOT, [{ uri: "at://a" }]); + assert.notEqual(registration.versionFor(context), before); + }); + + it("matches nothing for a key no cached projection has seen", async () => { + const dispatcher = makeDispatcher(); + const { registration } = register(dispatcher, { cacheKey: ["did"] }); + await registration.request({ uri: "at://a", did: "did:one" }); + dispatcher.refresh("alpha", SLOT, [{ did: "did:unknown" }]); + assert.deepEqual( + isPromise(registration.request({ uri: "at://a", did: "did:one" })), + false, + ); + }); + + it("rejects malformed keys without touching the cache", async () => { + const dispatcher = makeDispatcher(); + const { registration } = register(dispatcher, { cacheKey: ["did"] }); + await registration.request({ did: "did:one" }); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + const versionBefore = registration.versionFor({ did: "did:one" }); + for (const keys of [[{}], [], [{ did: 7 }], ["did:one"], {}]) { + dispatcher.refresh("alpha", SLOT, keys); + } + assert.deepEqual(warnings.length, 5); + assert.deepEqual( + isPromise(registration.request({ did: "did:one" })), + false, + ); + assert.deepEqual( + registration.versionFor({ did: "did:one" }), + versionBefore, + ); + } finally { + console.warn = originalWarn; + } + }); + + it("discards an in-flight result that a refresh invalidated", async () => { + const dispatcher = makeDispatcher(); + let resolveCall = null; + const dispose = dispatcher.register({ + pluginId: "alpha", + name: SLOT, + cacheKey: ["did"], + batch: true, + invoke: () => + new Promise((resolve) => { + resolveCall = resolve; + }), + }); + assert(dispose !== null); + const [registration] = dispatcher.getRegistrations(SLOT); + const pending = registration.request({ did: "did:one" }); + await Promise.resolve(); + dispatcher.refresh("alpha", SLOT); + resolveCall([{ value: { tag: "div", text: "stale" } }]); + await pending; + assert(isPromise(registration.request({ did: "did:one" }))); + }); + + it("rejects the caller when a batch item reports an error", async () => { + const dispatcher = makeDispatcher(); + dispatcher.register({ + pluginId: "alpha", + name: SLOT, + cacheKey: ["did"], + batch: true, + invoke: () => Promise.resolve([{ error: "boom" }]), + }); + const [registration] = dispatcher.getRegistrations(SLOT); + await assert.rejects(registration.request({ did: "did:one" }), /boom/); + // A failed call is not cached + const retry = registration.request({ did: "did:one" }); + assert(isPromise(retry)); + await assert.rejects(retry, /boom/); + }); + + it("rejects every caller when a plugin returns a malformed batch", async () => { + const dispatcher = makeDispatcher(); + dispatcher.register({ + pluginId: "alpha", + name: SLOT, + cacheKey: ["did"], + batch: true, + invoke: () => Promise.resolve("nope"), + }); + const [registration] = dispatcher.getRegistrations(SLOT); + await assert.rejects( + registration.request({ did: "did:one" }), + /malformed slot batch/, + ); + }); + + it("evicts least-recently-used values", async () => { + const dispatcher = makeDispatcher(); + const { registration } = register(dispatcher, { cacheKey: ["did"] }); + const requests = []; + for (let i = 0; i <= 200; i++) { + requests.push( + registration.request({ uri: `at://${i}`, did: `did:${i}` }), + ); + } + await Promise.all(requests); + // The cap holds - the one invariant here with no observable behavior + const cache = dispatcher._caches.get(JSON.stringify(["alpha", SLOT])); + assert.deepEqual(cache._values.size, 200); + assert(isPromise(registration.request({ did: "did:0" }))); + assert.deepEqual( + isPromise(registration.request({ uri: "at://200", did: "did:200" })), + false, + ); + }); + + it("drops a plugin's cache when its registration unregisters", async () => { + const dispatcher = makeDispatcher(); + const first = register(dispatcher, { cacheKey: ["did"] }); + await first.registration.request({ did: "did:one" }); + first.dispose(); + const second = register(dispatcher, { cacheKey: ["did"] }); + assert(isPromise(second.registration.request({ did: "did:one" }))); + await second.registration.request({ did: "did:one" }); + assert.deepEqual(second.calls.length, 1); + }); +}); + +describe("PluginSlotDispatcher - invocation monitor", () => { + function makeDispatcher() { + return new PluginSlotDispatcher({ monitor: new SlotInvocationMonitor() }); + } + + const warnings = []; + let now = 1_000_000; + const originalWarn = console.warn; + const originalNow = Date.now; + + beforeEach(() => { + warnings.length = 0; + now = 1_000_000; + console.warn = (...args) => warnings.push(args.join(" ")); + Date.now = () => now; + }); + + afterEach(() => { + console.warn = originalWarn; + Date.now = originalNow; + }); + + it("warns once when one context is re-invoked five times in the window", async () => { + const { registration } = register(makeDispatcher()); + for (let i = 0; i < 7; i++) { + await registration.request({ did: "did:one" }); + } + assert.deepEqual(warnings.length, 1); + assert(warnings[0].includes("5 times for the same context")); + assert(warnings[0].includes("refreshSlot loop")); + }); + + it("stays quiet for repeats spread across separate windows", async () => { + const { registration } = register(makeDispatcher()); + for (let i = 0; i < 10; i++) { + await registration.request({ did: "did:one" }); + now += 5001; + } + assert.deepEqual(warnings, []); + }); + + it("stays quiet for a burst of distinct contexts under the volume limit", async () => { + const { registration } = register(makeDispatcher()); + await Promise.all( + Array.from({ length: 40 }, (_, index) => + registration.request({ did: `did:${index}` }), + ), + ); + assert.deepEqual(warnings, []); + }); + + it("suggests a cacheKey when volume is high across distinct contexts", async () => { + const { registration } = register(makeDispatcher()); + await Promise.all( + Array.from({ length: 100 }, (_, index) => + registration.request({ did: `did:${index}` }), + ), + ); + assert.deepEqual(warnings.length, 1); + assert(warnings[0].includes("100 times in 5s across 100 contexts")); + assert(warnings[0].includes("Declare a cacheKey")); + }); + + it("points at an over-specific cacheKey when one is already declared", async () => { + const { registration } = register(makeDispatcher(), { cacheKey: ["uri"] }); + await Promise.all( + Array.from({ length: 100 }, (_, index) => + registration.request({ uri: `at://${index}` }), + ), + ); + assert.deepEqual(warnings.length, 1); + assert(warnings[0].includes("cacheKey (uri) may be too specific")); + }); + + it("counts cache hits as no invocation at all", async () => { + const { registration } = register(makeDispatcher(), { cacheKey: ["did"] }); + for (let i = 0; i < 20; i++) { + const request = registration.request({ + uri: `at://${i}`, + did: "did:one", + }); + if (isPromise(request)) await request; + } + assert.deepEqual(warnings, []); + }); +}); diff --git a/tests/unit/specs/router.test.js b/tests/unit/specs/router.test.js index 0ab41cc5..cb417aa6 100644 --- a/tests/unit/specs/router.test.js +++ b/tests/unit/specs/router.test.js @@ -1044,6 +1044,49 @@ describe("$currentRoute", () => { }); }); +describe("page cache", () => { + // mountRouter's root is detached, so "still rendered" means "still in the + // page container" rather than isConnected + function createRouter(paths) { + const router = new Router(); + const { defaultContainer } = mountRouter(router); + for (const path of paths) { + router.addRoute(path, () => Promise.resolve({})); + } + router.renderRoute(() => {}); + return { router, defaultContainer }; + } + + it("drops the least recently visited page once over the cap", async () => { + const paths = ["/a", "/b", "/c", "/d", "/e", "/f"]; + const { router, defaultContainer } = createRouter(paths); + for (const path of paths.slice(0, 5)) { + await router.load(path); + } + // Revisiting /a makes /b the coldest page + await router.load("/a"); + // peek, not get: reading through the cache would count as a visit + const pageB = router.pages.peek("/b").el; + + await router.load("/f"); + + assert.deepEqual(router.pages.has("/b"), false); + assert.deepEqual(defaultContainer.contains(pageB), false); + assert(router.pages.has("/a"), "the revisited page is kept"); + assert.deepEqual(router.pages.size, 5); + }); + + it("keeps a returned-to page rendered", async () => { + const { router, defaultContainer } = createRouter(["/a", "/b"]); + await router.load("/a"); + const pageA = router.pages.peek("/a").el; + await router.load("/b"); + await router.load("/a"); + assert.equal(router.pages.peek("/a").el, pageA); + assert(defaultContainer.contains(pageA)); + }); +}); + describe("scroll position persistence", () => { // JSDOM's window.scrollY is a read-only getter, so temporarily override it to // simulate the page being scrolled before we navigate away. diff --git a/tests/unit/specs/templates/largePost.template.test.js b/tests/unit/specs/templates/largePost.template.test.js index 3653eb52..5e3f4c84 100644 --- a/tests/unit/specs/templates/largePost.template.test.js +++ b/tests/unit/specs/templates/largePost.template.test.js @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { largePostTemplate } from "/js/templates/largePost.template.js"; import { post } from "../../testData.js"; import { render } from "/js/lib/lit-html.js"; +import { makeTestPluginService } from "../../testHelpers.js"; const noop = () => {}; const currentUser = { did: "did:plc:test" }; @@ -18,15 +19,7 @@ const postInteractionHandler = { handleReport: noop, }; -const pluginService = { - getPostContextMenuItems: async () => [], - $richTextTransformsVersion: { get: () => 0 }, - transformRichTextTokens: async () => null, - renderRichTextNodeToken: () => null, - getClaimedFacetTypes: () => new Set(), - $slots: { get: () => null }, - getSlotEntries: () => [], -}; +const pluginService = makeTestPluginService(); const baseProps = { currentUser, diff --git a/tests/unit/specs/templates/postEmbed.template.test.js b/tests/unit/specs/templates/postEmbed.template.test.js index a17c9bce..1d10035e 100644 --- a/tests/unit/specs/templates/postEmbed.template.test.js +++ b/tests/unit/specs/templates/postEmbed.template.test.js @@ -6,15 +6,9 @@ import { } from "/js/templates/postEmbed.template.js"; import { post } from "../../testData.js"; import { render } from "/js/lib/lit-html.js"; +import { makeTestPluginService } from "../../testHelpers.js"; -const pluginService = { - $richTextTransformsVersion: { get: () => 0 }, - transformRichTextTokens: async () => null, - renderRichTextNodeToken: () => null, - getClaimedFacetTypes: () => new Set(), - $slots: { get: () => null }, - getSlotEntries: () => [], -}; +const pluginService = makeTestPluginService(); describe("postEmbedTemplate - images", () => { it("should render image embed", () => { diff --git a/tests/unit/specs/templates/smallPost.template.test.js b/tests/unit/specs/templates/smallPost.template.test.js index b1b45084..f70231ee 100644 --- a/tests/unit/specs/templates/smallPost.template.test.js +++ b/tests/unit/specs/templates/smallPost.template.test.js @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { smallPostTemplate } from "/js/templates/smallPost.template.js"; import { post } from "../../testData.js"; import { render } from "/js/lib/lit-html.js"; +import { makeTestPluginService } from "../../testHelpers.js"; const noop = () => {}; const currentUser = { did: "did:plc:test" }; @@ -18,15 +19,7 @@ const postInteractionHandler = { handleReport: noop, }; -const pluginService = { - getPostContextMenuItems: async () => [], - $richTextTransformsVersion: { get: () => 0 }, - transformRichTextTokens: async () => null, - renderRichTextNodeToken: () => null, - getClaimedFacetTypes: () => new Set(), - $slots: { get: () => null }, - getSlotEntries: () => [], -}; +const pluginService = makeTestPluginService(); const baseProps = { currentUser, diff --git a/tests/unit/specs/utils.test.js b/tests/unit/specs/utils.test.js index 07ba8bdb..fd9b507a 100644 --- a/tests/unit/specs/utils.test.js +++ b/tests/unit/specs/utils.test.js @@ -27,6 +27,10 @@ import { pinScrollPosition, KVIndexedDB, isOnlyEmoji, + batchPerTick, + BoundedMap, + AsyncValueCache, + isPromise, } from "/js/utils.js"; import { installFakeIndexedDB } from "../testHelpers.js"; @@ -1417,3 +1421,261 @@ describe("KVIndexedDB", () => { await assert.rejects(db.get("a"), /open denied/); }); }); + +describe("batchPerTick", () => { + it("collects calls made in one microtask into a single batch", async () => { + const batches = []; + const call = batchPerTick((items) => { + batches.push(items); + return items.map((item) => item * 2); + }); + const results = await Promise.all([call(1), call(2), call(3)]); + assert.deepEqual(batches, [[1, 2, 3]]); + assert.deepEqual(results, [2, 4, 6]); + }); + + it("resolves each caller with the result at its own position", async () => { + const call = batchPerTick((items) => items.map((item) => `${item}!`)); + const [second, first] = await Promise.all([call("b"), call("a")]); + assert.deepEqual(second, "b!"); + assert.deepEqual(first, "a!"); + }); + + it("starts a fresh batch for calls made after a flush", async () => { + const batches = []; + const call = batchPerTick((items) => { + batches.push(items); + return items; + }); + await call("a"); + await call("b"); + assert.deepEqual(batches, [["a"], ["b"]]); + }); + + it("does not include calls made during a flush in the running batch", async () => { + const batches = []; + const call = batchPerTick(async (items) => { + batches.push(items); + return items; + }); + const first = call("a"); + const second = first.then(() => call("b")); + await Promise.all([first, second]); + assert.deepEqual(batches, [["a"], ["b"]]); + }); + + it("rejects only the caller whose result is an Error", async () => { + const call = batchPerTick((items) => + items.map((item) => (item === "bad" ? new Error("boom") : item)), + ); + const results = await Promise.allSettled([call("bad"), call("good")]); + assert.deepEqual(results[0].status, "rejected"); + assert.deepEqual(results[0].reason.message, "boom"); + assert.deepEqual(results[1].status, "fulfilled"); + assert.deepEqual(results[1].value, "good"); + }); + + it("rejects every caller in the batch when the batch function throws", async () => { + const call = batchPerTick(() => { + throw new Error("boom"); + }); + const results = await Promise.allSettled([call(1), call(2)]); + assert.deepEqual( + results.map((result) => result.status), + ["rejected", "rejected"], + ); + assert.deepEqual(results[0].reason.message, "boom"); + assert.deepEqual(results[1].reason.message, "boom"); + }); + + it("rejects the batch when the batch function returns the wrong number of results", async () => { + const call = batchPerTick((items) => items.slice(1)); + const results = await Promise.allSettled([call(1), call(2)]); + assert.deepEqual( + results.map((result) => result.status), + ["rejected", "rejected"], + ); + assert(/expected 2 results, got 1/.test(results[0].reason.message)); + }); + + it("keeps working after a failed batch", async () => { + let shouldFail = true; + const call = batchPerTick((items) => { + if (shouldFail) { + shouldFail = false; + throw new Error("boom"); + } + return items; + }); + await assert.rejects(call("a"), /boom/); + assert.deepEqual(await call("b"), "b"); + }); +}); + +describe("BoundedMap", () => { + it("behaves like a Map below the cap", () => { + const map = new BoundedMap(3); + map.set("a", 1).set("b", 2); + assert.deepEqual(map.get("a"), 1); + assert.deepEqual(map.size, 2); + assert(map.has("b")); + }); + + it("evicts the oldest entry once the cap is exceeded", () => { + const map = new BoundedMap(2); + map.set("a", 1).set("b", 2).set("c", 3); + assert.deepEqual([...map.keys()], ["b", "c"]); + }); + + it("reports each evicted entry", () => { + const evicted = []; + const map = new BoundedMap(1, { + onEvict: (key, value) => evicted.push([key, value]), + }); + map.set("a", 1).set("b", 2).set("c", 3); + assert.deepEqual(evicted, [ + ["a", 1], + ["b", 2], + ]); + }); + + it("evicts the coldest entry under the lru policy", () => { + const map = new BoundedMap(2, { policy: "lru" }); + map.set("a", 1).set("b", 2); + map.get("a"); + map.set("c", 3); + assert.deepEqual([...map.keys()], ["a", "c"]); + }); + + it("does not count a peek as use under the lru policy", () => { + const map = new BoundedMap(2, { policy: "lru" }); + map.set("a", 1).set("b", 2); + assert.deepEqual(map.peek("a"), 1); + map.set("c", 3); + assert.deepEqual([...map.keys()], ["b", "c"]); + }); + + it("ignores reads under the default fifo policy", () => { + const map = new BoundedMap(2); + map.set("a", 1).set("b", 2); + map.get("a"); + map.set("c", 3); + assert.deepEqual([...map.keys()], ["b", "c"]); + }); + + it("keeps an entry alive when it is re-set on read", () => { + const map = new BoundedMap(2); + map.set("a", 1).set("b", 2); + // The least-recently-used idiom: delete + set moves the entry to the end + map.delete("a"); + map.set("a", 1); + map.set("c", 3); + assert.deepEqual([...map.keys()], ["a", "c"]); + }); + + it("does not evict when overwriting an existing key at the cap", () => { + const evicted = []; + const map = new BoundedMap(2, { onEvict: (key) => evicted.push(key) }); + map.set("a", 1).set("b", 2).set("b", 3); + assert.deepEqual([...map.keys()], ["a", "b"]); + assert.deepEqual(map.get("b"), 3); + assert.deepEqual(evicted, []); + }); +}); + +describe("AsyncValueCache", () => { + function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + } + + it("reports a miss, then serves the stored value synchronously", async () => { + const cache = new AsyncValueCache(10); + const miss = cache.request("a", async () => "A"); + assert(isPromise(miss)); + assert.deepEqual(await miss, "A"); + + const hit = cache.request("a", async () => "SHOULD NOT RUN"); + assert.deepEqual(isPromise(hit), false); + assert.deepEqual(hit, "A"); + }); + + it("shares one run between concurrent requests for a key", async () => { + const cache = new AsyncValueCache(10); + let runs = 0; + const run = async () => { + runs += 1; + return "A"; + }; + const first = cache.request("a", run); + const second = cache.request("a", run); + assert.equal(second, first); + assert.deepEqual(await Promise.all([first, second]), ["A", "A"]); + assert.deepEqual(runs, 1); + }); + + it("does not cache a result whose entry was invalidated mid-flight", async () => { + const cache = new AsyncValueCache(10); + const gate = deferred(); + const request = cache.request("a", () => gate.promise); + cache.invalidate(); + gate.resolve("A"); + assert.deepEqual(await request, "A"); + // The caller still gets its result; the cache doesn't keep it + assert.deepEqual(cache.peek("a"), null); + }); + + it("does not let a superseded run clobber a newer one", async () => { + const cache = new AsyncValueCache(10); + const first = deferred(); + const stale = cache.request("a", () => first.promise); + cache.invalidate(); + const second = deferred(); + const fresh = cache.request("a", () => second.promise); + + second.resolve("FRESH"); + await fresh; + first.resolve("STALE"); + await stale; + + assert.deepEqual(cache.peek("a").value, "FRESH"); + }); + + it("does not cache failures, and retries on the next request", async () => { + const cache = new AsyncValueCache(10); + await assert.rejects( + cache.request("a", async () => { + throw new Error("boom"); + }), + /boom/, + ); + assert.deepEqual(cache.peek("a"), null); + assert.deepEqual(await cache.request("a", async () => "A"), "A"); + }); + + it("invalidates only the keys a predicate matches", async () => { + const cache = new AsyncValueCache(10); + await cache.request("a", async () => "A"); + await cache.request("b", async () => "B"); + cache.invalidate((key) => key === "a"); + assert.deepEqual(cache.peek("a"), null); + assert.deepEqual(cache.peek("b").value, "B"); + }); + + it("evicts least-recently-used entries at the cap", async () => { + const cache = new AsyncValueCache(2); + await cache.request("a", async () => "A"); + await cache.request("b", async () => "B"); + // Reading "a" makes "b" the coldest entry + cache.request("a", async () => "SHOULD NOT RUN"); + await cache.request("c", async () => "C"); + assert.deepEqual(cache.size, 2); + assert.deepEqual(cache.peek("b"), null); + assert.deepEqual(cache.peek("a").value, "A"); + }); +}); diff --git a/tests/unit/testHelpers.js b/tests/unit/testHelpers.js index f76106d1..87f79ef9 100644 --- a/tests/unit/testHelpers.js +++ b/tests/unit/testHelpers.js @@ -3,6 +3,7 @@ import { DataLayer } from "/js/dataLayer/dataLayer.js"; import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js"; import { DraftMediaStore } from "/js/drafts.js"; import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js"; +import { Signal, SignalMap } from "/js/signals.js"; export function makeTestDataLayer({ api: apiOverrides = {}, @@ -27,6 +28,24 @@ export function makeTestDataLayer({ ); } +export function makeTestPluginService(overrides = {}) { + return { + $slots: new SignalMap(), + $richTextTransformsVersion: new Signal.State(0), + getSlotRegistrations: () => [], + getRenderer: () => ({ + createRoot: () => ({ render: () => null, reset: () => {} }), + }), + transformRichTextTokens: async () => null, + renderRichTextNodeToken: () => null, + getClaimedFacetTypes: () => new Set(), + getSidebarItems: () => [], + getPostContextMenuItems: async () => [], + getProfileContextMenuItems: async () => [], + ...overrides, + }; +} + // Stubs the four declarative.ensure* record-resolution methods with plausible // default fixtures. Pass overrides for methods a test needs to control. export function stubRecordLinkResolution(dataLayer, overrides = {}) { -- 2.51.2