Skip to content
5 changes: 5 additions & 0 deletions .changeset/cozy-maps-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@elgato/streamdeck": major
---

Removed `useExperimentalMessageIdentifiers`, now enabled by default.
5 changes: 5 additions & 0 deletions .changeset/gentle-parrots-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@elgato/streamdeck": major
---

Updated `onDidReceiveSettings` and `onDidReceiveGlobalSettings` to only fire when settings are changed in the property inspector (requires Stream Deck 7.1 or higher). This behavior can be temporarily reverted by configuring `streamDeck.settings.useLegacySettingsBehavior` to be `true`.
53 changes: 47 additions & 6 deletions packages/plugin/src/plugin/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
type GetGlobalSettings,
type SetGlobalSettings,
} from "../../api/index.js";
import type { Action } from "../actions/action.js";
import { settingsCache } from "../actions/cache.js";
import { actionStore } from "../actions/store.js";
import { connection } from "../connection.js";
import { Device } from "../devices/device.js";
Expand Down Expand Up @@ -97,8 +99,48 @@ describe("settings", () => {
});
});

describe("receiving emits with useExperimentalMessageIdentifiers set to false", () => {
beforeAll(() => (settings.useExperimentalMessageIdentifiers = false));
describe("useLegacySettingsBehavior", () => {
/**
* Asserts that switching useLegacySettingsBehavior clears the settings cache.
*/
it("clears settings cache when switching modes", () => {
// Arrange.
const testContext = "cache-clear-test-context";
settings.useLegacySettingsBehavior = false;
settingsCache.set(testContext, { name: "Cached" });
expect(settingsCache.get(testContext)).toBeDefined();

// Act.
settings.useLegacySettingsBehavior = true;

// Assert.
expect(settingsCache.get(testContext)).toBeUndefined();

// Cleanup.
settings.useLegacySettingsBehavior = false;
});

/**
* Asserts that switching useLegacySettingsBehavior back to false also clears the cache.
*/
it("clears settings cache when switching from true to false", () => {
// Arrange.
const testContext = "cache-clear-test-context-2";
settings.useLegacySettingsBehavior = true;
settingsCache.set(testContext, { name: "Cached" });
expect(settingsCache.get(testContext)).toBeDefined();

// Act.
settings.useLegacySettingsBehavior = false;

// Assert.
expect(settingsCache.get(testContext)).toBeUndefined();
});
});

describe("receiving emits with useLegacySettingsBehavior set to true", () => {
beforeAll(() => (settings.useLegacySettingsBehavior = true));
afterAll(() => (settings.useLegacySettingsBehavior = false));

/**
* Asserts {@link onDidReceiveGlobalSettings} is invoked when `didReceiveGlobalSettings` is emitted.
Expand Down Expand Up @@ -170,7 +212,7 @@ describe("settings", () => {
// Assert (emit).
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith<[DidReceiveSettingsEvent<Settings>]>({
action: actionStore.getActionById(ev.context)!,
action: actionStore.getActionById(ev.context)! as Action<Settings>,
payload: ev.payload,
type: "didReceiveSettings",
});
Expand All @@ -184,9 +226,8 @@ describe("settings", () => {
});
});

describe("receiving does not emit with useExperimentalMessageIdentifiers set to true", () => {
beforeAll(() => (settings.useExperimentalMessageIdentifiers = true));
afterAll(() => (settings.useExperimentalMessageIdentifiers = false));
describe("receiving does not emit with useLegacySettingsBehavior set to false", () => {
beforeAll(() => (settings.useLegacySettingsBehavior = false));

test("didReceiveGlobalSettings", () => {
// Arrange.
Expand Down
7 changes: 4 additions & 3 deletions packages/plugin/src/plugin/__tests__/ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type PropertyInspectorDidDisappear,
type SendToPropertyInspector,
} from "../../api/index.js";
import type { Action } from "../actions/action.js";
import { KeyAction } from "../actions/key.js";
import { actionStore } from "../actions/store.js";
import { connection } from "../connection.js";
Expand Down Expand Up @@ -64,7 +65,7 @@ describe("UIController", () => {
// Assert (emit).
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith<[PropertyInspectorDidAppearEvent<Settings>]>({
action: actionStore.getActionById(ev.context)!,
action: actionStore.getActionById(ev.context) as Action<Settings>,
type: "propertyInspectorDidAppear",
});

Expand Down Expand Up @@ -96,7 +97,7 @@ describe("UIController", () => {
// Assert (emit).
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith<[PropertyInspectorDidDisappearEvent<Settings>]>({
action: actionStore.getActionById(ev.context)!,
action: actionStore.getActionById(ev.context) as Action<Settings>,
type: "propertyInspectorDidDisappear",
});

Expand Down Expand Up @@ -130,7 +131,7 @@ describe("UIController", () => {
// Assert (emit).
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith<[SendToPluginEvent<Settings, Settings>]>({
action: actionStore.getActionById(ev.context)!,
action: actionStore.getActionById(ev.context) as Action<Settings>,
payload: {
name: "Hello world",
},
Expand Down
22 changes: 7 additions & 15 deletions packages/plugin/src/plugin/actions/__tests__/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ describe("Action", () => {

beforeAll(() => vi.spyOn(deviceStore, "getDeviceById").mockReturnValue(device));
afterEach(() => {
actionConfig.useExperimentalMessageIdentifiers = false;
settingsCache.delete(source.context);
vi.clearAllMocks();
});
Expand All @@ -75,14 +74,8 @@ describe("Action", () => {
expect(deviceStore.getDeviceById).toHaveBeenLastCalledWith(source.device);
});

describe("with useExperimentalMessageIdentifiers set to true", () => {
beforeEach(() => {
actionConfig.useExperimentalMessageIdentifiers = true;
});

afterAll(() => {
actionConfig.useExperimentalMessageIdentifiers = false;
});
describe("with useLegacySettingsBehavior set to false", () => {
beforeEach(() => (actionConfig.useLegacySettingsBehavior = false));

/**
* Asserts {@link ActionBase.getSettings} returns cached settings when the cache is valid.
Expand Down Expand Up @@ -112,13 +105,12 @@ describe("Action", () => {
});
});

describe("with useExperimentalMessageIdentifiers set to false", () => {
beforeAll(() => {
actionConfig.useExperimentalMessageIdentifiers = false;
});
describe("with useLegacySettingsBehavior set to true", () => {
beforeAll(() => (actionConfig.useLegacySettingsBehavior = true));
afterAll(() => (actionConfig.useLegacySettingsBehavior = false));

/**
* Asserts {@link ActionBase.getSettings} ignores cached settings when experimental message identifiers are disabled.
* Asserts {@link ActionBase.getSettings} ignores cached settings when legacy settings behavior is enabled.
*/
it("getSettings ignores cached settings", async () => {
// Arrange.
Expand Down Expand Up @@ -292,7 +284,7 @@ describe("Action", () => {

describe("sending", () => {
let action!: KeyAction<Settings>;
beforeAll(() => (action = new KeyAction(source)));
beforeAll(() => (action = new KeyAction(source as WillAppear<Settings>)));

/**
* Asserts {@link ActionBase.setSettings} invalidates the settings cache.
Expand Down
62 changes: 60 additions & 2 deletions packages/plugin/src/plugin/actions/__tests__/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ describe("actions", () => {
});

afterEach(() => {
actionConfig.useExperimentalMessageIdentifiers = false;
vi.clearAllMocks();
});

Expand Down Expand Up @@ -581,7 +580,7 @@ describe("actions", () => {
*/
it("updates settings cache on willAppear/didReceiveSettings and clears on willDisappear", () => {
// Arrange.
actionConfig.useExperimentalMessageIdentifiers = true;
actionConfig.useLegacySettingsBehavior = false;
const context = "cache-lifecycle-context";
settingsCache.delete(context);

Expand Down Expand Up @@ -654,6 +653,65 @@ describe("actions", () => {
connection.emit("willDisappear", willDisappear);
expect(settingsCache.get(context)).toBeUndefined();
});

/**
* Asserts settings cache is not updated when legacy settings behavior is enabled.
*/
it("does not update settings cache when useLegacySettingsBehavior is true", () => {
// Arrange.
actionConfig.useLegacySettingsBehavior = true;
const context = "cache-skip-context";
settingsCache.delete(context);

const willAppear = {
action: "com.elgato.test.key",
context,
device: "device123",
event: "willAppear",
payload: {
controller: "Keypad",
coordinates: {
column: 1,
row: 1,
},
isInMultiAction: false,
resources: {},
settings: {
name: "FromAppear",
},
},
} satisfies WillAppear<Settings>;

const didReceiveSettings = {
action: "com.elgato.test.key",
context,
device: "device123",
event: "didReceiveSettings",
payload: {
controller: "Keypad",
coordinates: {
column: 1,
row: 1,
},
isInMultiAction: false,
resources: {},
settings: {
name: "Updated",
},
},
} satisfies DidReceiveSettings<Settings>;

// Act, assert (cache should NOT be set on appear).
connection.emit("willAppear", willAppear);
expect(settingsCache.get(context)).toBeUndefined();

// Act, assert (cache should NOT be updated on settings event).
connection.emit("didReceiveSettings", didReceiveSettings);
expect(settingsCache.get(context)).toBeUndefined();

// Cleanup.
actionConfig.useLegacySettingsBehavior = false;
});
});

describe("registering an action", () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/plugin/src/plugin/actions/action-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class ActionBase<TSettings extends JsonObject> extends ActionContext {
* @returns Promise containing the action instance's settings.
*/
public async getSettings(): Promise<TSettings> {
if (actionConfig.useExperimentalMessageIdentifiers) {
if (!actionConfig.useLegacySettingsBehavior) {
Comment thread
GeekyEggo marked this conversation as resolved.
const cached = settingsCache.get(this.id);
if (cached !== undefined) {
logger.trace(
Expand All @@ -56,6 +56,7 @@ export class ActionBase<TSettings extends JsonObject> extends ActionContext {
settings: cached,
}),
);

return cached as TSettings;
}
}
Expand Down
7 changes: 7 additions & 0 deletions packages/plugin/src/plugin/actions/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ class SettingsCache {
*/
readonly #entries = new Map<string, JsonObject>();

/**
* Clears the cached settings.
*/
public clear(): void {
this.#entries.clear();
}

/**
* Removes the cached settings for the specified action.
* @param id Action instance identifier.
Expand Down
12 changes: 10 additions & 2 deletions packages/plugin/src/plugin/actions/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@
*/
export const actionConfig = {
/**
* Determines whether settings requests should use message identifiers and action settings cache behavior.
* Determines the behavior of when `onDidReceiveSettings` and `onDidReceiveGlobalSettings` are fired.
*
* - `false` (default) — `onDidReceiveSettings` and `onDidReceiveGlobalSettings` are only fired
* after the settings were updated within the property inspector.
* - `true` — `onDidReceiveSettings` and `onDidReceiveGlobalSettings` are fired after the settings
* were updated within the property inspector, and after calling `action.getSettings()` and
* `streamDeck.settings.getGlobalSettings()` respectively.
*
* This option replaces `useExperimentalMessageIdentifiers`, with inverted behavior.
*/
useExperimentalMessageIdentifiers: false,
useLegacySettingsBehavior: false,
};
4 changes: 2 additions & 2 deletions packages/plugin/src/plugin/actions/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ class ActionService extends ReadOnlyActionStore {
const action = this.#createAction(ev);

actionStore.set(action);
if (actionConfig.useExperimentalMessageIdentifiers) {
if (!actionConfig.useLegacySettingsBehavior) {
Comment thread
GeekyEggo marked this conversation as resolved.
settingsCache.set(ev.context, ev.payload.settings);
}
});

// Update the settings cache when settings are received.
connection.prependListener("didReceiveSettings", (ev) => {
if (actionConfig.useExperimentalMessageIdentifiers) {
if (!actionConfig.useLegacySettingsBehavior) {
settingsCache.set(ev.context, ev.payload.settings);
}
});
Expand Down
5 changes: 4 additions & 1 deletion packages/plugin/src/plugin/actions/singleton-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ export class SingletonAction<T extends JsonObject = JsonObject> {
public onDidReceiveResources?(ev: DidReceiveResourcesEvent<T>): Promise<void> | void;

/**
* Occurs when the settings associated with an action instance are requested using {@link ActionBase.getSettings}, or when the settings were updated by the property inspector.
* Occurs when the settings were updated within the property inspector.
*
* When `streamDeck.settings.useLegacySettingsBehavior` is set to `true`, this also fires after
* calling `getSettings()`.
* @param ev Information about the event, including the source action and contextual payload information.
*/
public onDidReceiveSettings?(ev: DidReceiveSettingsEvent<T>): Promise<void> | void;
Expand Down
8 changes: 4 additions & 4 deletions packages/plugin/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { deviceService, type DeviceService } from "./devices/service.js";
import { fileSystemLocaleProvider } from "./i18n.js";
import { logger } from "./logging/index.js";
import * as profiles from "./profiles.js";
import { settings } from "./settings.js";
import { settings, validateSettingsBehavior } from "./settings.js";
import * as system from "./system.js";
import { ui, type UIController } from "./ui.js";

Expand Down Expand Up @@ -113,10 +113,10 @@ export const streamDeck = {

/**
* Connects the plugin to the Stream Deck.
* @returns A promise resolved when a connection has been established.
*/
connect(): Promise<void> {
return connection.connect();
async connect(): Promise<void> {
validateSettingsBehavior();
await connection.connect();
},
};

Expand Down
Loading
Loading