diff --git a/.api-reports/api-report-dev.md b/.api-reports/api-report-dev.md index 8f5baa9c781..9158589f56c 100644 --- a/.api-reports/api-report-dev.md +++ b/.api-reports/api-report-dev.md @@ -14,17 +14,25 @@ interface ErrorCodes { }; } +// @public +export type ErrorMessageHandler = { + (message: string | number, args: string[]): string | undefined; +}; + // @public (undocumented) export function loadDevMessages(): void; // Warning: (ae-forgotten-export) The symbol "ErrorCodes" needs to be exported by the entry point index.d.ts // -// @public (undocumented) -export function loadErrorMessageHandler(...errorCodes: ErrorCodes[]): ((message: string | number, args: unknown[]) => string | undefined) & ErrorCodes; +// @public +export function loadErrorMessageHandler(...errorCodes: ErrorCodes[]): ErrorMessageHandler & ErrorCodes; // @public (undocumented) export function loadErrorMessages(): void; +// @public +export function setErrorMessageHandler(handler: ErrorMessageHandler): void; + // (No @packageDocumentation comment for this package) ``` diff --git a/.changeset/eleven-doors-rescue.md b/.changeset/eleven-doors-rescue.md new file mode 100644 index 00000000000..89bc6bf6dbe --- /dev/null +++ b/.changeset/eleven-doors-rescue.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Expose `setErrorMessageHandler` from `@apollo/client/dev` entrypoint. diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 9a16f9572d9..a2e89a93514 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -145,6 +145,7 @@ Array [ "loadDevMessages", "loadErrorMessageHandler", "loadErrorMessages", + "setErrorMessageHandler", ] `; diff --git a/src/dev/index.ts b/src/dev/index.ts index 7d6b01f873f..bf77b7e5326 100644 --- a/src/dev/index.ts +++ b/src/dev/index.ts @@ -1,3 +1,5 @@ export { loadDevMessages } from "./loadDevMessages.js"; export { loadErrorMessageHandler } from "./loadErrorMessageHandler.js"; export { loadErrorMessages } from "./loadErrorMessages.js"; +export { setErrorMessageHandler } from "./setErrorMessageHandler.js"; +export type { ErrorMessageHandler } from "./setErrorMessageHandler.js"; diff --git a/src/dev/loadErrorMessageHandler.ts b/src/dev/loadErrorMessageHandler.ts index ad18c159ed5..8f99a34ae9a 100644 --- a/src/dev/loadErrorMessageHandler.ts +++ b/src/dev/loadErrorMessageHandler.ts @@ -1,27 +1,31 @@ import type { ErrorCodes } from "../invariantErrorCodes.js"; import { global } from "../utilities/globals/index.js"; import { ApolloErrorMessageHandler } from "../utilities/globals/invariantWrappers.js"; +import type { ErrorMessageHandler } from "./setErrorMessageHandler.js"; +import { setErrorMessageHandler } from "./setErrorMessageHandler.js"; +/** + * Injects Apollo Client's default error message handler into the application and + * also loads the error codes that are passed in as arguments. + */ export function loadErrorMessageHandler(...errorCodes: ErrorCodes[]) { - if (!global[ApolloErrorMessageHandler]) { - global[ApolloErrorMessageHandler] = handler as typeof handler & ErrorCodes; - } + setErrorMessageHandler(handler as typeof handler & ErrorCodes); for (const codes of errorCodes) { - Object.assign(global[ApolloErrorMessageHandler], codes); + Object.assign(handler, codes); } - return global[ApolloErrorMessageHandler]; + return handler; +} - function handler(message: string | number, args: unknown[]) { - if (typeof message === "number") { - const definition = global[ApolloErrorMessageHandler]![message]; - if (!message || !definition?.message) return; - message = definition.message; - } - return args.reduce( - (msg, arg) => msg.replace(/%[sdfo]/, String(arg)), - String(message) - ); +const handler = ((message: string | number, args: unknown[]) => { + if (typeof message === "number") { + const definition = global[ApolloErrorMessageHandler]![message]; + if (!message || !definition?.message) return; + message = definition.message; } -} + return args.reduce( + (msg, arg) => msg.replace(/%[sdfo]/, String(arg)), + String(message) + ); +}) as ErrorMessageHandler & ErrorCodes; diff --git a/src/dev/setErrorMessageHandler.ts b/src/dev/setErrorMessageHandler.ts new file mode 100644 index 00000000000..2dc822d0485 --- /dev/null +++ b/src/dev/setErrorMessageHandler.ts @@ -0,0 +1,40 @@ +import type { ErrorCodes } from "../invariantErrorCodes.js"; +import { global } from "../utilities/globals/index.js"; +import { ApolloErrorMessageHandler } from "../utilities/globals/invariantWrappers.js"; + +/** + * The error message handler is a function that is called when a message is + * logged or an error is thrown to determine the contents of the error message + * to be logged or thrown. + */ +export type ErrorMessageHandler = { + /** + * @param message - Usually the error message number (as defined in + * `@apollo/client/invariantErrorCodes.js`). + * In some edge cases, this can already be a string, that can be passed through + * as an error message. + * + * @param args - The placeholders that can be passed into the error message (pre-stringified). + * These relate with the `%s` and `%d` [substitution strings](https://developer.mozilla.org/en-US/docs/Web/API/console#using_string_substitutions) + * in the error message defined in `@apollo/client/invariantErrorCodes.js`. + * + * ⚠️ Note that arguments will only be passed in for error messages. + * For normal log messages, you will get an empty array here and they will directly + * be passed to `console.log` instead, to have the string subsitution done by the + * engine, as that allows for nicer (and in the case of a browser, interactive) + * output. + * + * @returns The error message to be logged or thrown. If it returns `undefined`, + * the mechanism will fall back to the default: + * A link to https://go.apollo.dev/c/err with Apollo Client version, + * the error message number, and the error message arguments encoded into + * the URL hash. + */ (message: string | number, args: string[]): string | undefined; +}; + +/** + * Overrides the global "Error Message Handler" with a custom implementation. + */ +export function setErrorMessageHandler(handler: ErrorMessageHandler) { + global[ApolloErrorMessageHandler] = handler as typeof handler & ErrorCodes; +} diff --git a/src/utilities/globals/__tests__/invariantWrappers.test.ts b/src/utilities/globals/__tests__/invariantWrappers.test.ts index 2b750c534ed..b8f51390944 100644 --- a/src/utilities/globals/__tests__/invariantWrappers.test.ts +++ b/src/utilities/globals/__tests__/invariantWrappers.test.ts @@ -1,34 +1,56 @@ -import { loadErrorMessageHandler } from "../../../dev"; -import { spyOnConsole, withCleanup } from "../../../testing/internal"; +import { spyOnConsole } from "../../../testing/internal"; import { ApolloErrorMessageHandler, InvariantError, invariant, } from "../invariantWrappers"; +function withDev() { + const originalErrorMessageHandler = window[ApolloErrorMessageHandler]; + window[ApolloErrorMessageHandler] = undefined; + let dev: typeof import("../../../dev"); + let restore = () => {}; + // we're running the test inside of `jest.isolateModulesAsync` to avoid + // the test overriding the module-level state of the `dev` module + const cleanupFinished = jest.isolateModulesAsync( + () => + new Promise((resolve) => { + dev = require("../../../dev"); + restore = resolve; + }) + ); + // replicate the code of `src/config/jest/setup.ts` + dev!.loadErrorMessageHandler(); + return { + ...dev!, + async [Symbol.asyncDispose]() { + restore(); + await cleanupFinished; + window[ApolloErrorMessageHandler] = originalErrorMessageHandler; + }, + }; +} + function disableErrorMessageHandler() { - const original = window[ApolloErrorMessageHandler]; + // eslint-disable-next-line local-rules/require-using-disposable + const dev = withDev(); delete window[ApolloErrorMessageHandler]; - return withCleanup({ original }, ({ original }) => { - window[ApolloErrorMessageHandler] = original; - }); + return dev; } function mockErrorMessageHandler() { - const original = window[ApolloErrorMessageHandler]; + // eslint-disable-next-line local-rules/require-using-disposable + const dev = withDev(); delete window[ApolloErrorMessageHandler]; - loadErrorMessageHandler({ + dev.loadErrorMessageHandler({ 5: { file: "foo", message: "Replacing %s, %d, %f, %o" }, }); - - return withCleanup({ original }, ({ original }) => { - window[ApolloErrorMessageHandler] = original; - }); + return dev; } -test("base invariant(false, 5, ...), no handlers", () => { - using _ = disableErrorMessageHandler(); +test("base invariant(false, 5, ...), no handlers", async () => { + await using _ = disableErrorMessageHandler(); expect(() => { invariant(false, 5, "string", 1, 1.1, { a: 1 }); }).toThrow( @@ -50,29 +72,47 @@ test("base invariant(false, 5, ...), no handlers", () => { ); }); -test("base invariant(false, 5, ...), handlers in place", () => { - using _ = mockErrorMessageHandler(); +test("base invariant(false, 5, ...), handlers in place", async () => { + await using _ = mockErrorMessageHandler(); expect(() => { invariant(false, 5, "string", 1, 1.1, { a: 1 }); }).toThrow(new InvariantError('Replacing string, 1, 1.1, {\n "a": 1\n}')); }); -test("base invariant(false, undefined), no handlers", () => { - using _ = disableErrorMessageHandler(); +test("base invariant(false, 5, ...), custom handler gets passed arguments", async () => { + await using dev = disableErrorMessageHandler(); + + const handler = jest.fn(() => ""); + dev.setErrorMessageHandler(handler); + + try { + invariant(false, 5, "string", 1, 1.1, { a: 1 }); + } catch {} + + expect(handler).toHaveBeenCalledWith(5, [ + "string", + "1", + "1.1", + '{\n "a": 1\n}', + ]); +}); + +test("base invariant(false, undefined), no handlers", async () => { + await using _ = disableErrorMessageHandler(); expect(() => { invariant(false); }).toThrow(new InvariantError("Invariant Violation")); }); -test("base invariant(false, undefined), handlers in place", () => { - using _ = mockErrorMessageHandler(); +test("base invariant(false, undefined), handlers in place", async () => { + await using _ = mockErrorMessageHandler(); expect(() => { invariant(false); }).toThrow(new InvariantError("Invariant Violation")); }); -test("invariant.log(5, ...), no handlers", () => { - using _ = disableErrorMessageHandler(); +test("invariant.log(5, ...), no handlers", async () => { + await using _ = disableErrorMessageHandler(); using consoleSpy = spyOnConsole("log"); invariant.log(5, "string", 1, 1.1, { a: 1 }); expect(consoleSpy.log).toHaveBeenCalledWith( @@ -87,8 +127,8 @@ test("invariant.log(5, ...), no handlers", () => { ); }); -test("invariant.log(5, ...), with handlers", () => { - using _ = mockErrorMessageHandler(); +test("invariant.log(5, ...), with handlers", async () => { + await using _ = mockErrorMessageHandler(); using consoleSpy = spyOnConsole("log"); invariant.log(5, "string", 1, 1.1, { a: 1 }); expect(consoleSpy.log).toHaveBeenCalledWith( @@ -100,8 +140,22 @@ test("invariant.log(5, ...), with handlers", () => { ); }); -test("base invariant(false, 6, ...), raises fallback", () => { - using _ = mockErrorMessageHandler(); +test("invariant.log(5, ...), custom handler does not get passed arguments", async () => { + await using dev = disableErrorMessageHandler(); + using _consoleSpy = spyOnConsole("log"); + + const handler = jest.fn(() => ""); + dev.setErrorMessageHandler(handler); + + try { + invariant.log(5, "string", 1, 1.1, { a: 1 }); + } catch {} + + expect(handler).toHaveBeenCalledWith(5, []); +}); + +test("base invariant(false, 6, ...), raises fallback", async () => { + await using _ = mockErrorMessageHandler(); expect(() => { invariant(false, 6, "hello"); }).toThrow( diff --git a/src/utilities/globals/invariantWrappers.ts b/src/utilities/globals/invariantWrappers.ts index 6ea4c4d884b..81070cbbb7d 100644 --- a/src/utilities/globals/invariantWrappers.ts +++ b/src/utilities/globals/invariantWrappers.ts @@ -111,7 +111,7 @@ const ApolloErrorMessageHandler = Symbol.for( declare global { interface Window { [ApolloErrorMessageHandler]?: { - (message: string | number, args: unknown[]): string | undefined; + (message: string | number, args: string[]): string | undefined; } & ErrorCodes; } }