From 30a7b0d76279d6ed29189c043769c67e97475281 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Thu, 26 Sep 2024 14:08:19 -0400 Subject: [PATCH 1/2] #9202: add with cache brick with memoization/expiry (#9203) --- .../controlFlow/WithAsyncModVariable.test.ts | 11 +- .../controlFlow/WithCache.test.ts | 305 ++++++++++++ .../transformers/controlFlow/WithCache.ts | 451 ++++++++++++++++++ src/bricks/transformers/getAllTransformers.ts | 2 + src/development/headers.test.ts | 2 +- .../pipelineTests/pipelineTestHelpers.ts | 3 +- 6 files changed, 764 insertions(+), 10 deletions(-) create mode 100644 src/bricks/transformers/controlFlow/WithCache.test.ts create mode 100644 src/bricks/transformers/controlFlow/WithCache.ts 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 }; } } From 004f615019d24086539b1f81f3020b912f260dfd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 14:10:10 -0500 Subject: [PATCH 2/2] Bump date-fns from 3.6.0 to 4.1.0 (#9185) Bumps [date-fns](https://github.com/date-fns/date-fns) from 3.6.0 to 4.1.0. - [Release notes](https://github.com/date-fns/date-fns/releases) - [Changelog](https://github.com/date-fns/date-fns/blob/main/CHANGELOG.md) - [Commits](https://github.com/date-fns/date-fns/compare/v3.6.0...v4.1.0) --- updated-dependencies: - dependency-name: date-fns dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index c48f8e2c63..49b7d3a757 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "copy-text-to-clipboard": "^3.2.0", "csharp-helpers": "^0.9.3", "css-selector-generator": "^3.6.8", - "date-fns": "^3.6.0", + "date-fns": "^4.1.0", "dompurify": "^3.1.6", "downloadjs": "^1.4.7", "exifreader": "^4.23.5", @@ -13085,9 +13085,9 @@ } }, "node_modules/date-fns": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", - "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" diff --git a/package.json b/package.json index b6708fc9a4..dd95ffdf13 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "copy-text-to-clipboard": "^3.2.0", "csharp-helpers": "^0.9.3", "css-selector-generator": "^3.6.8", - "date-fns": "^3.6.0", + "date-fns": "^4.1.0", "dompurify": "^3.1.6", "downloadjs": "^1.4.7", "exifreader": "^4.23.5",