diff --git a/src/bricks/transformers/controlFlow/WithAsyncModVariable.test.ts b/src/bricks/transformers/controlFlow/WithAsyncModVariable.test.ts
index 6ec1995ab2..1cd2aa590d 100644
--- a/src/bricks/transformers/controlFlow/WithAsyncModVariable.test.ts
+++ b/src/bricks/transformers/controlFlow/WithAsyncModVariable.test.ts
@@ -31,8 +31,9 @@ import { type Expression } from "@/types/runtimeTypes";
import { toExpression } from "@/utils/expressionUtils";
import { modComponentRefFactory } from "@/testUtils/factories/modComponentFactories";
import { reduceOptionsFactory } from "@/testUtils/factories/runtimeFactories";
-import { MergeStrategies, StateNamespaces } from "@/platform/state/stateTypes";
+import { StateNamespaces } from "@/platform/state/stateTypes";
import { getPlatform } from "@/platform/platformContext";
+import { TEST_resetStateController } from "@/contentScript/stateController/stateController";
const withAsyncModVariableBrick = new WithAsyncModVariable();
@@ -71,13 +72,7 @@ describe("WithAsyncModVariable", () => {
let asyncEchoBrick: DeferredEchoBrick;
beforeEach(async () => {
- // Reset the page state to avoid interference between tests
- await getPlatform().state.setState({
- namespace: StateNamespaces.MOD,
- data: {},
- modComponentRef,
- mergeStrategy: MergeStrategies.REPLACE,
- });
+ await TEST_resetStateController();
// Most tests just require a single brick instance for testing
deferred = pDefer();
diff --git a/src/bricks/transformers/controlFlow/WithCache.test.ts b/src/bricks/transformers/controlFlow/WithCache.test.ts
new file mode 100644
index 0000000000..f8908646bf
--- /dev/null
+++ b/src/bricks/transformers/controlFlow/WithCache.test.ts
@@ -0,0 +1,305 @@
+/*
+ * Copyright (C) 2024 PixieBrix, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import type { Brick } from "@/types/brickTypes";
+import type { Expression } from "@/types/runtimeTypes";
+import { toExpression } from "@/utils/expressionUtils";
+import { modComponentRefFactory } from "@/testUtils/factories/modComponentFactories";
+import { getPlatform } from "@/platform/platformContext";
+import { StateNamespaces } from "@/platform/state/stateTypes";
+import { WithCache } from "@/bricks/transformers/controlFlow/WithCache";
+import pDefer, { type DeferredPromise } from "p-defer";
+import {
+ DeferredEchoBrick,
+ simpleInput,
+ throwBrick,
+ echoBrick,
+} from "@/runtime/pipelineTests/pipelineTestHelpers";
+import brickRegistry from "@/bricks/registry";
+import { TEST_resetStateController } from "@/contentScript/stateController/stateController";
+import { reducePipeline } from "@/runtime/reducePipeline";
+import { reduceOptionsFactory } from "@/testUtils/factories/runtimeFactories";
+import { tick } from "@/starterBricks/starterBrickTestUtils";
+import { CancelError } from "@/errors/businessErrors";
+import { ContextError } from "@/errors/genericErrors";
+import { sleep } from "@/utils/timeUtils";
+
+const withCacheBrick = new WithCache();
+
+const STATE_KEY = "testVariable";
+
+function makeCachePipeline(
+ brick: Brick,
+ {
+ message,
+ stateKey,
+ forceFetch = false,
+ ttl = null,
+ }: {
+ message: string;
+ forceFetch?: boolean;
+ stateKey: string | Expression;
+ ttl?: number | null;
+ },
+) {
+ return {
+ id: withCacheBrick.id,
+ config: {
+ body: toExpression("pipeline", [
+ {
+ id: brick.id,
+ config: {
+ message,
+ },
+ },
+ ]),
+ stateKey,
+ forceFetch,
+ ttl,
+ },
+ };
+}
+
+const modComponentRef = modComponentRefFactory();
+
+async function expectPageState(expectedState: UnknownObject): Promise {
+ const pageState = await getPlatform().state.getState({
+ namespace: StateNamespaces.MOD,
+ modComponentRef,
+ });
+
+ expect(pageState).toStrictEqual(expectedState);
+}
+
+describe("WithCache", () => {
+ let deferred: DeferredPromise;
+ let asyncEchoBrick: DeferredEchoBrick;
+
+ beforeEach(async () => {
+ await TEST_resetStateController();
+
+ // Most tests just require a single brick instance for testing
+ deferred = pDefer();
+ asyncEchoBrick = new DeferredEchoBrick(deferred.promise);
+
+ brickRegistry.clear();
+ brickRegistry.register([
+ asyncEchoBrick,
+ throwBrick,
+ echoBrick,
+ withCacheBrick,
+ ]);
+ });
+
+ it("returns value if pipeline succeeds", async () => {
+ const pipeline = makeCachePipeline(echoBrick, {
+ stateKey: STATE_KEY,
+ message: "bar",
+ });
+
+ const brickOutput = await reducePipeline(
+ pipeline,
+ simpleInput({}),
+ reduceOptionsFactory("v3", { modComponentRef }),
+ );
+
+ const expectedData = { message: "bar" };
+
+ expect(brickOutput).toStrictEqual(expectedData);
+
+ await expectPageState({
+ [STATE_KEY]: {
+ isLoading: false,
+ isFetching: false,
+ isSuccess: true,
+ isError: false,
+ data: expectedData,
+ currentData: expectedData,
+ requestId: expect.toBeString(),
+ error: null,
+ expiresAt: null,
+ },
+ });
+ });
+
+ it("throws exception if pipeline throws", async () => {
+ const pipeline = makeCachePipeline(throwBrick, {
+ stateKey: STATE_KEY,
+ message: "bar",
+ });
+
+ const brickPromise = reducePipeline(
+ pipeline,
+ simpleInput({}),
+ reduceOptionsFactory("v3", { modComponentRef }),
+ );
+
+ await expect(brickPromise).rejects.toThrow("bar");
+ });
+
+ it("memoizes value", async () => {
+ const firstCallPipeline = makeCachePipeline(asyncEchoBrick, {
+ stateKey: STATE_KEY,
+ message: "first",
+ });
+
+ const firstCallPromise = reducePipeline(
+ firstCallPipeline,
+ simpleInput({}),
+ reduceOptionsFactory("v3", { modComponentRef }),
+ );
+
+ // Wait for the initial fetching state to be set
+ await tick();
+
+ const secondCallPipeline = makeCachePipeline(asyncEchoBrick, {
+ stateKey: STATE_KEY,
+ message: "second",
+ });
+
+ const secondCallPromise = reducePipeline(
+ secondCallPipeline,
+ simpleInput({}),
+ reduceOptionsFactory("v3", { modComponentRef }),
+ );
+
+ deferred.resolve();
+
+ const target = { message: "first" };
+
+ await expect(firstCallPromise).resolves.toStrictEqual(target);
+ await expect(secondCallPromise).resolves.toStrictEqual(target);
+ });
+
+ it("respects TTL", async () => {
+ const firstCallPipeline = makeCachePipeline(echoBrick, {
+ stateKey: STATE_KEY,
+ message: "first",
+ // Zero seconds to avoid needing to mock timers
+ ttl: 0,
+ });
+
+ const firstCallPromise = reducePipeline(
+ firstCallPipeline,
+ simpleInput({}),
+ reduceOptionsFactory("v3", { modComponentRef }),
+ );
+
+ // Wait for the initial fetching state to be set
+ await tick();
+
+ await sleep(1);
+
+ const secondCallPipeline = makeCachePipeline(asyncEchoBrick, {
+ stateKey: STATE_KEY,
+ message: "second",
+ });
+
+ const secondCallPromise = reducePipeline(
+ secondCallPipeline,
+ simpleInput({}),
+ reduceOptionsFactory("v3", { modComponentRef }),
+ );
+
+ // Wait for 2nd promise to replace the request id
+ await tick();
+
+ deferred.resolve();
+
+ try {
+ await firstCallPromise;
+ } catch (error) {
+ expect(error).toBeInstanceOf(ContextError);
+ expect((error as Error).cause).toBeInstanceOf(CancelError);
+ }
+
+ await expect(secondCallPromise).resolves.toStrictEqual({
+ message: "second",
+ });
+ });
+
+ it("memoizes error", async () => {
+ const pipeline = makeCachePipeline(asyncEchoBrick, {
+ stateKey: STATE_KEY,
+ message: "bar",
+ });
+
+ const requesterPromise = reducePipeline(
+ pipeline,
+ simpleInput({}),
+ reduceOptionsFactory("v3", { modComponentRef }),
+ );
+
+ // Let the initial isFetching state be set
+ await tick();
+
+ const memoizedPromise = reducePipeline(
+ pipeline,
+ simpleInput({}),
+ reduceOptionsFactory("v3", { modComponentRef }),
+ );
+
+ deferred.reject(new Error("Test Error"));
+
+ await expect(requesterPromise).rejects.toThrow("Test Error");
+ await expect(memoizedPromise).rejects.toThrow("Test Error");
+ });
+
+ it("forces fetch", async () => {
+ const firstCallPipeline = makeCachePipeline(asyncEchoBrick, {
+ stateKey: STATE_KEY,
+ message: "first",
+ });
+
+ const firstCallPromise = reducePipeline(
+ firstCallPipeline,
+ simpleInput({}),
+ reduceOptionsFactory("v3", { modComponentRef }),
+ );
+
+ // Wait for the initial fetching state to be set
+ await tick();
+
+ const secondCallPipeline = makeCachePipeline(asyncEchoBrick, {
+ stateKey: STATE_KEY,
+ message: "second",
+ forceFetch: true,
+ });
+
+ const secondCallPromise = reducePipeline(
+ secondCallPipeline,
+ simpleInput({}),
+ reduceOptionsFactory("v3", { modComponentRef }),
+ );
+
+ // Wait for 2nd call to override the request id
+ await tick();
+
+ deferred.resolve();
+
+ try {
+ await firstCallPromise;
+ } catch (error) {
+ expect(error).toBeInstanceOf(ContextError);
+ expect((error as Error).cause).toBeInstanceOf(CancelError);
+ }
+
+ await expect(secondCallPromise).resolves.toStrictEqual({
+ message: "second",
+ });
+ });
+});
diff --git a/src/bricks/transformers/controlFlow/WithCache.ts b/src/bricks/transformers/controlFlow/WithCache.ts
new file mode 100644
index 0000000000..664e0068f0
--- /dev/null
+++ b/src/bricks/transformers/controlFlow/WithCache.ts
@@ -0,0 +1,451 @@
+/*
+ * Copyright (C) 2024 PixieBrix, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { TransformerABC } from "@/types/bricks/transformerTypes";
+import { uuidv4, validateRegistryId } from "@/types/helpers";
+import { type Schema } from "@/types/schemaTypes";
+import {
+ type BrickArgs,
+ type BrickOptions,
+ type PipelineExpression,
+} from "@/types/runtimeTypes";
+import { deserializeError, serializeError } from "serialize-error";
+import { type JsonObject } from "type-fest";
+import { isNullOrBlank } from "@/utils/stringUtils";
+import { isEmpty } from "lodash";
+import { BusinessError, CancelError, PropError } from "@/errors/businessErrors";
+import { type BrickConfig } from "@/bricks/types";
+import { castTextLiteralOrThrow } from "@/utils/expressionUtils";
+import { propertiesToSchema } from "@/utils/schemaUtils";
+import {
+ MergeStrategies,
+ STATE_CHANGE_JS_EVENT_TYPE,
+ StateNamespaces,
+} from "@/platform/state/stateTypes";
+import { ContextError } from "@/errors/genericErrors";
+import pDefer from "p-defer";
+import type { UUID } from "@/types/stringTypes";
+
+type Args = {
+ body: PipelineExpression;
+ stateKey: string;
+ ttl?: number;
+ forceFetch?: boolean;
+};
+
+type CacheVariableState = {
+ requestId: UUID;
+ isFetching: boolean;
+ isError: boolean;
+ isSuccess: boolean;
+ error: JsonObject | null;
+ data: JsonObject;
+ expiresAt: number | null;
+};
+
+function isCacheVariableState(value: unknown): value is CacheVariableState {
+ if (typeof value !== "object" || value == null) {
+ return false;
+ }
+
+ const { requestId, isFetching, isError, expiresAt } = value as UnknownObject;
+
+ if (typeof isFetching !== "boolean") {
+ return false;
+ }
+
+ if (typeof isError !== "boolean") {
+ return false;
+ }
+
+ if (typeof requestId !== "string") {
+ return false;
+ }
+
+ if (expiresAt != null && typeof expiresAt !== "number") {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * A brick that runs synchronously and caches the result in a Mod Variable. Repeat calls are memoized until settled.
+ *
+ * The state shape is defined to be similar to RTK Query and our async hooks:
+ * https://redux-toolkit.js.org/rtk-query/api/created-api/hooks#usequery
+ */
+export class WithCache extends TransformerABC {
+ static readonly BRICK_ID = validateRegistryId("@pixiebrix/cache");
+
+ constructor() {
+ super(
+ WithCache.BRICK_ID,
+ "Run with Cache",
+ "Run bricks synchronously and cache the status and result in a Mod Variable",
+ );
+ }
+
+ override async isPure(): Promise {
+ // Modifies the Page State
+ return false;
+ }
+
+ override async isPageStateAware(): Promise {
+ return true;
+ }
+
+ inputSchema: Schema = propertiesToSchema(
+ {
+ body: {
+ $ref: "https://app.pixiebrix.com/schemas/pipeline#",
+ title: "Body",
+ description: "The bricks to run asynchronously",
+ },
+ stateKey: {
+ title: "Mod Variable Name",
+ type: "string",
+ description: "The Mod Variable to store the status and data in",
+ },
+ ttl: {
+ title: "Time-to-Live (s)",
+ type: "integer",
+ description:
+ "The time-to-live for the cached value in seconds. If not provided, the value will not expire. Expiry is calculated from the start of the run.",
+ },
+ forceFetch: {
+ title: "Force Fetch",
+ type: "boolean",
+ description:
+ "If toggled on, the cache will be ignored and the body always be run.",
+ },
+ },
+ ["body", "stateKey"],
+ );
+
+ override defaultOutputKey = "cachedValue";
+
+ override async getModVariableSchema(
+ _config: BrickConfig,
+ ): Promise {
+ const { stateKey } = _config.config;
+
+ let name: string | null = null;
+ try {
+ name = castTextLiteralOrThrow(stateKey);
+ } catch {
+ return;
+ }
+
+ if (name) {
+ return {
+ type: "object",
+ properties: {
+ [name]: {
+ type: "object",
+ properties: {
+ isLoading: {
+ type: "boolean",
+ },
+ isFetching: {
+ type: "boolean",
+ },
+ isSuccess: {
+ type: "boolean",
+ },
+ isError: {
+ type: "boolean",
+ },
+ currentData: {},
+ data: {},
+ expiresAt: {
+ type: "integer",
+ },
+ requestId: {
+ type: "string",
+ format: "uuid",
+ },
+ error: {
+ type: "object",
+ },
+ },
+ additionalProperties: false,
+ required: [
+ "isLoading",
+ "isFetching",
+ "isSuccess",
+ "isError",
+ "currentData",
+ "data",
+ "requestId",
+ "error",
+ ],
+ },
+ },
+ additionalProperties: false,
+ required: [name],
+ };
+ }
+
+ return {
+ type: "object",
+ additionalProperties: true,
+ };
+ }
+
+ private async waitForSettledRequest({
+ requestId,
+ args: { stateKey },
+ options,
+ }: {
+ requestId: string;
+ args: BrickArgs;
+ options: BrickOptions;
+ }): Promise {
+ // Coalesce multiple requests into a single request
+ const {
+ meta: { modComponentRef },
+ platform,
+ abortSignal,
+ } = options;
+
+ const deferredValuePromise = pDefer();
+
+ document.addEventListener(
+ STATE_CHANGE_JS_EVENT_TYPE,
+ async () => {
+ const stateUpdate = await platform.state.getState({
+ namespace: StateNamespaces.MOD,
+ modComponentRef,
+ });
+
+ // eslint-disable-next-line security/detect-object-injection -- user provided value that's readonly
+ const variableUpdate = (stateUpdate[stateKey] ?? {}) as JsonObject;
+
+ if (!isCacheVariableState(variableUpdate)) {
+ deferredValuePromise.reject(
+ new BusinessError(
+ "Invalid cache shape. Cache value was overwritten.",
+ ),
+ );
+ }
+
+ if (variableUpdate.requestId !== requestId) {
+ deferredValuePromise.reject(
+ new CancelError("Value generation was superseded"),
+ );
+ }
+
+ if (!variableUpdate.isFetching) {
+ if (variableUpdate.isError) {
+ deferredValuePromise.reject(deserializeError(variableUpdate.error));
+ }
+
+ deferredValuePromise.resolve(variableUpdate.data);
+ }
+
+ // Ignore state change if still fetching
+ },
+ { signal: abortSignal },
+ );
+
+ return deferredValuePromise.promise;
+ }
+
+ private async generateValue({
+ currentVariable,
+ args: { stateKey, ttl, body },
+ options,
+ }: {
+ currentVariable: CacheVariableState | null;
+ args: BrickArgs;
+ options: BrickOptions;
+ }): Promise {
+ // Perform a new request
+ const requestId = uuidv4();
+ const expiresAt = ttl == null ? null : Date.now() + ttl * 1000;
+
+ const {
+ meta: { modComponentRef },
+ runPipeline,
+ platform,
+ } = options;
+
+ const isCurrentNonce = async (query: string) => {
+ const currentState = await platform.state.getState({
+ namespace: StateNamespaces.MOD,
+ modComponentRef,
+ });
+
+ // eslint-disable-next-line security/detect-object-injection -- user provided value that's readonly
+ const currentVariable = (currentState[stateKey] ?? {}) as JsonObject;
+
+ const { requestId: currentRequestId } = currentVariable;
+
+ return currentRequestId == null || currentRequestId === query;
+ };
+
+ const setModVariable = async (data: JsonObject) => {
+ await platform.state.setState({
+ // Store as Mod Variable
+ namespace: StateNamespaces.MOD,
+ modComponentRef,
+ // Using shallow will replace the state key, but keep other keys. Always pass the full state object in order
+ // to ensure the shape is valid.
+ mergeStrategy: MergeStrategies.SHALLOW,
+ data: {
+ [stateKey]: data,
+ },
+ });
+ };
+
+ if (currentVariable == null) {
+ // Initialize the mod variable.
+ await setModVariable({
+ requestId,
+ // Don't set expiresAt until the value is set
+ expiresAt: null,
+ isLoading: true,
+ isFetching: true,
+ isSuccess: false,
+ isError: false,
+ currentData: null,
+ data: null,
+ error: null,
+ });
+ } else {
+ await setModVariable({
+ // Preserve the previous data/error, if any. Due to get/setState being async, it's possible that
+ // the state could have been deleted since the getState call. Therefore, pass a full state object
+ ...currentVariable,
+ requestId,
+ isFetching: true,
+ currentData: null,
+ });
+ }
+
+ let data: JsonObject;
+
+ try {
+ data = (await runPipeline(body, {
+ key: "body",
+ counter: 0,
+ })) as JsonObject;
+ } catch (_error) {
+ if (!(await isCurrentNonce(requestId))) {
+ throw new CancelError("Value generation was superseded");
+ }
+
+ await setModVariable({
+ isLoading: false,
+ isFetching: false,
+ isSuccess: false,
+ isError: true,
+ currentData: null,
+ data: null,
+ requestId,
+ error: serializeError(_error) as JsonObject,
+ });
+
+ throw new ContextError("An error occurred generating the cached value", {
+ cause: _error,
+ });
+ }
+
+ if (!(await isCurrentNonce(requestId))) {
+ throw new CancelError("Value generation was superseded");
+ }
+
+ await setModVariable({
+ isLoading: false,
+ isFetching: false,
+ isSuccess: true,
+ isError: false,
+ currentData: data,
+ data,
+ requestId,
+ error: null,
+ // Record expiresAt (if provided) when the value is set
+ expiresAt,
+ });
+
+ return data;
+ }
+
+ async transform(args: BrickArgs, options: BrickOptions) {
+ const { stateKey, forceFetch = false } = args;
+
+ const {
+ meta: { modComponentRef },
+ platform,
+ } = options;
+
+ if (isNullOrBlank(stateKey)) {
+ throw new PropError(
+ "Mod Variable Name is required",
+ this.id,
+ "stateKey",
+ stateKey,
+ );
+ }
+
+ // `getState/setState` are async. So calls need to account for interlacing state modifications (including deletes).
+ // The main concern is ensuring the shape of the async state is always valid.
+ // We don't need to have strong consistency guarantees on which calls "win" if the state is updated concurrently.
+ const currentState = await platform.state.getState({
+ namespace: StateNamespaces.MOD,
+ modComponentRef,
+ });
+
+ // eslint-disable-next-line security/detect-object-injection -- user provided value that's readonly
+ const currentVariable = (currentState[stateKey] ?? {}) as JsonObject;
+
+ if (!isEmpty(currentVariable) && !isCacheVariableState(currentVariable)) {
+ throw new BusinessError(
+ "Invalid cache shape. Cache value was overwritten.",
+ );
+ }
+
+ if (!forceFetch && isCacheVariableState(currentVariable)) {
+ if (currentVariable.isFetching) {
+ return this.waitForSettledRequest({
+ requestId: currentVariable.requestId,
+ args,
+ options,
+ });
+ }
+
+ if (
+ // Don't throw settled exceptions/rejections
+ currentVariable.isSuccess &&
+ // Only refetch if the cached value is still valid w.r.t. the TTL
+ (currentVariable.expiresAt == null ||
+ Date.now() < currentVariable.expiresAt)
+ ) {
+ return currentVariable.data;
+ }
+ }
+
+ return this.generateValue({
+ currentVariable: isCacheVariableState(currentVariable)
+ ? currentVariable
+ : null,
+ args,
+ options,
+ });
+ }
+}
diff --git a/src/bricks/transformers/getAllTransformers.ts b/src/bricks/transformers/getAllTransformers.ts
index 2bdd71978e..2baebd4300 100644
--- a/src/bricks/transformers/getAllTransformers.ts
+++ b/src/bricks/transformers/getAllTransformers.ts
@@ -61,6 +61,7 @@ import type { RegistryId, RegistryProtocol } from "@/types/registryTypes";
import RunBrickByIdTransformer from "@/bricks/transformers/RunBrickByIdTransformer";
import GetBrickInterfaceTransformer from "@/bricks/transformers/GetBrickInterfaceTransformer";
import RunMetadataTransformer from "@/bricks/transformers/RunMetadataTransformer";
+import { WithCache } from "@/bricks/transformers/controlFlow/WithCache";
function getAllTransformers(
registry: RegistryProtocol,
@@ -115,6 +116,7 @@ function getAllTransformers(
new Run(),
new MapValues(),
new WithAsyncModVariable(),
+ new WithCache(),
// Render Pipelines
new DisplayTemporaryInfo(),
diff --git a/src/development/headers.test.ts b/src/development/headers.test.ts
index a9ce51b302..be3455f5cb 100644
--- a/src/development/headers.test.ts
+++ b/src/development/headers.test.ts
@@ -25,7 +25,7 @@ import registerBuiltinBricks from "@/bricks/registerBuiltinBricks";
import registerContribBricks from "@/contrib/registerContribBricks";
// Maintaining this number is a simple way to ensure bricks don't accidentally get dropped
-const EXPECTED_HEADER_COUNT = 137;
+const EXPECTED_HEADER_COUNT = 138;
registerBuiltinBricks();
registerContribBricks();
diff --git a/src/runtime/pipelineTests/pipelineTestHelpers.ts b/src/runtime/pipelineTests/pipelineTestHelpers.ts
index 178e861c97..0a9bbd94fb 100644
--- a/src/runtime/pipelineTests/pipelineTestHelpers.ts
+++ b/src/runtime/pipelineTests/pipelineTestHelpers.ts
@@ -109,7 +109,9 @@ class FeatureFlagBrick extends BrickABC {
export class DeferredEchoBrick extends BrickABC {
static BRICK_ID = validateRegistryId("test/deferred");
+
readonly promiseOrFactory: Promise | (() => Promise);
+
constructor(promiseOrFactory: Promise | (() => Promise)) {
super(DeferredEchoBrick.BRICK_ID, "Deferred Brick");
this.promiseOrFactory = promiseOrFactory;
@@ -131,7 +133,6 @@ export class DeferredEchoBrick extends BrickABC {
await this.promiseOrFactory();
}
- await this.promiseOrFactory;
return { message };
}
}