Skip to content

Commit

Permalink
expose setErrorMessageHandler (#11694)
Browse files Browse the repository at this point in the history
* expose `setErrorMessageHandler`

* changeset

* Clean up Prettier, Size-limit, and Api-Extractor

* add export to exports shape test

* add some clarifying comments and two more tests

* Clean up Prettier, Size-limit, and Api-Extractor

* Update eleven-doors-rescue.md

---------

Co-authored-by: phryneas <[email protected]>
  • Loading branch information
phryneas and phryneas authored Mar 19, 2024
1 parent 3203cb8 commit 835d5f3
Show file tree
Hide file tree
Showing 8 changed files with 159 additions and 45 deletions.
12 changes: 10 additions & 2 deletions .api-reports/api-report-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

```
5 changes: 5 additions & 0 deletions .changeset/eleven-doors-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Expose `setErrorMessageHandler` from `@apollo/client/dev` entrypoint.
1 change: 1 addition & 0 deletions src/__tests__/__snapshots__/exports.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ Array [
"loadDevMessages",
"loadErrorMessageHandler",
"loadErrorMessages",
"setErrorMessageHandler",
]
`;

Expand Down
2 changes: 2 additions & 0 deletions src/dev/index.ts
Original file line number Diff line number Diff line change
@@ -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";
36 changes: 20 additions & 16 deletions src/dev/loadErrorMessageHandler.ts
Original file line number Diff line number Diff line change
@@ -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<string>(
(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<string>(
(msg, arg) => msg.replace(/%[sdfo]/, String(arg)),
String(message)
);
}) as ErrorMessageHandler & ErrorCodes;
40 changes: 40 additions & 0 deletions src/dev/setErrorMessageHandler.ts
Original file line number Diff line number Diff line change
@@ -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;
}
106 changes: 80 additions & 26 deletions src/utilities/globals/__tests__/invariantWrappers.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/utilities/globals/invariantWrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down

0 comments on commit 835d5f3

Please sign in to comment.