Skip to content

Commit 572cbbc

Browse files
authored
[@fluent/react] Provided better errors when components are mis-configured (#549)
* Throw an error when the LocalizationProvider is not properly initialized The current behavior is permissive, and works without warnings. This change makes it so that omitting the LocalizationProvider throws an error. I was originally trying to decide between throwing an error and emitting a console error. I opted for an error to have the behavior in line with what the react-redux Provider does. This is potentially a breaking change for users that are relying on the LocalizationProvider being omitted before the bundles are loaded. This behavior can still be achieved by providing an empty bundle list to the LocalizationProvider. * Report an error when there is no matching message id * Fix lint issues
1 parent 0f1c563 commit 572cbbc

12 files changed

+232
-56
lines changed

fluent-react/src/.eslintrc.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
extends: "../../eslint_ts.json"
3+
}

fluent-react/src/context.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createContext } from "react";
22
import { ReactLocalization } from "./localization";
33

4-
export let FluentContext = createContext(new ReactLocalization([], null));
4+
export let FluentContext =
5+
createContext(null) as React.Context<ReactLocalization | null>;

fluent-react/src/localization.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ export class ReactLocalization {
3030
return mapBundleSync(this.bundles, id);
3131
}
3232

33+
areBundlesEmpty(): boolean {
34+
// Create an iterator and only peek at the first value to see if it contains
35+
// anything.
36+
return Boolean(this.bundles[Symbol.iterator]().next().done);
37+
}
38+
3339
getString(
3440
id: string,
3541
args?: Record<string, FluentVariable> | null,
@@ -46,6 +52,22 @@ export class ReactLocalization {
4652
}
4753
return value;
4854
}
55+
} else {
56+
if (this.areBundlesEmpty()) {
57+
this.reportError(
58+
new Error(
59+
"Attempting to get a string when no localization bundles are " +
60+
"present."
61+
)
62+
);
63+
} else {
64+
this.reportError(
65+
new Error(
66+
`The id "${id}" did not match any messages in the localization `
67+
+ "bundles."
68+
)
69+
);
70+
}
4971
}
5072

5173
return fallback || id;

fluent-react/src/localized.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,36 @@ export function Localized(props: LocalizedProps): ReactElement {
6666
}
6767

6868
if (!l10n) {
69-
// Use the wrapped component as fallback.
70-
return createElement(Fragment, null, child);
69+
throw new Error(
70+
"The <Localized /> component was not properly wrapped in a "
71+
+ "<LocalizationProvider />."
72+
);
7173
}
7274

7375
const bundle = l10n.getBundle(id);
7476

7577
if (bundle === null) {
78+
if (id === undefined) {
79+
l10n.reportError(
80+
new Error("No id was provided for a <Localized /> component.")
81+
);
82+
} else {
83+
if (l10n.areBundlesEmpty()) {
84+
l10n.reportError(
85+
new Error(
86+
"A <Localized /> component was rendered when no localization "
87+
+ "bundles are present."
88+
)
89+
);
90+
} else {
91+
l10n.reportError(
92+
new Error(
93+
`The id "${id}" did not match any messages in the localization `
94+
+ "bundles."
95+
)
96+
);
97+
}
98+
}
7699
// Use the wrapped component as fallback.
77100
return createElement(Fragment, null, child);
78101
}

fluent-react/src/use_localization.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,12 @@ type useLocalization = () => { l10n: ReactLocalization }
99
export const useLocalization: useLocalization = () => {
1010
const l10n = useContext(FluentContext);
1111

12+
if (!l10n) {
13+
throw new Error(
14+
"useLocalization was used without wrapping it in a "
15+
+ "<LocalizationProvider />."
16+
);
17+
}
18+
1219
return { l10n };
1320
};

fluent-react/src/with_localization.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export function withLocalization<P extends WithLocalizationProps>(
1717
): ComponentType<WithoutLocalizationProps<P>> {
1818
function WithLocalization(props: WithoutLocalizationProps<P>): ReactElement {
1919
const l10n = useContext(FluentContext);
20+
if (!l10n) {
21+
throw new Error(
22+
"withLocalization was used without wrapping it in a "
23+
+ "<LocalizationProvider />."
24+
);
25+
}
2026
// Re-bind getString to trigger a re-render of Inner.
2127
const getString = l10n.getString.bind(l10n);
2228
return createElement(Inner, { getString, ...props } as P);

fluent-react/test/.eslintrc.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
extends: "../../eslint_test.json"
3+
}

fluent-react/test/localized_fallback.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ foo = FOO
6161
});
6262

6363
test("falls back back for missing message", function() {
64+
jest.spyOn(console, "warn").mockImplementation(() => {});
65+
6466
const bundle1 = new FluentBundle();
6567
bundle1.addResource(
6668
new FluentResource(`
@@ -81,4 +83,12 @@ not-foo = NOT FOO
8183
Bar
8284
</div>
8385
`);
86+
87+
expect(console.warn.mock.calls).toMatchInlineSnapshot(`
88+
Array [
89+
Array [
90+
"[@fluent/react] Error: The id \\"foo\\" did not match any messages in the localization bundles.",
91+
],
92+
]
93+
`);
8494
});

fluent-react/test/localized_render.test.js

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -336,11 +336,11 @@ foo = { $arg }
336336
});
337337

338338
test("render with a fragment and no message preserves the fragment", () => {
339-
const bundle = new FluentBundle();
339+
jest.spyOn(console, "warn").mockImplementation(() => {});
340340

341341
const renderer = TestRenderer.create(
342-
<LocalizationProvider l10n={new ReactLocalization([bundle])}>
343-
<Localized id="foo">
342+
<LocalizationProvider l10n={new ReactLocalization([])}>
343+
<Localized id="non-matching-id">
344344
<React.Fragment>
345345
<div>Fragment content</div>
346346
</React.Fragment>
@@ -349,10 +349,11 @@ foo = { $arg }
349349
);
350350

351351
expect(renderer.toJSON()).toMatchInlineSnapshot(`
352-
<div>
353-
Fragment content
354-
</div>
355-
`);
352+
<div>
353+
Fragment content
354+
</div>
355+
`);
356+
expect(console.warn).toHaveBeenCalled();
356357
});
357358

358359
test("A missing $arg does not break rendering", () => {
@@ -442,6 +443,7 @@ foo = Test message
442443

443444
test("render with an empty fragment and no message preserves the fragment", () => {
444445
const bundle = new FluentBundle();
446+
jest.spyOn(console, "warn").mockImplementation(() => {});
445447

446448
const renderer = TestRenderer.create(
447449
<LocalizationProvider l10n={new ReactLocalization([bundle])}>
@@ -452,6 +454,13 @@ foo = Test message
452454
);
453455

454456
expect(renderer.toJSON()).toMatchInlineSnapshot(`null`);
457+
expect(console.warn.mock.calls).toMatchInlineSnapshot(`
458+
Array [
459+
Array [
460+
"[@fluent/react] Error: The id \\"foo\\" did not match any messages in the localization bundles.",
461+
],
462+
]
463+
`);
455464
});
456465

457466
test("render with an empty fragment and no message value preserves the fragment", () => {
@@ -494,6 +503,7 @@ foo = Test message
494503
});
495504

496505
test("render with a string fallback and no message returns the fallback", () => {
506+
jest.spyOn(console, "warn").mockImplementation(() => {});
497507
const bundle = new FluentBundle();
498508

499509
const renderer = TestRenderer.create(
@@ -503,6 +513,13 @@ foo = Test message
503513
);
504514

505515
expect(renderer.toJSON()).toMatchInlineSnapshot(`"String fallback"`);
516+
expect(console.warn.mock.calls).toMatchInlineSnapshot(`
517+
Array [
518+
Array [
519+
"[@fluent/react] Error: The id \\"foo\\" did not match any messages in the localization bundles.",
520+
],
521+
]
522+
`);
506523
});
507524

508525
test("render with a string fallback returns the message", () => {
@@ -541,6 +558,7 @@ foo = Message
541558
});
542559

543560
test("render without a fallback and no message returns nothing", () => {
561+
jest.spyOn(console, "warn").mockImplementation(() => {});
544562
const bundle = new FluentBundle();
545563

546564
const renderer = TestRenderer.create(
@@ -550,5 +568,12 @@ foo = Message
550568
);
551569

552570
expect(renderer.toJSON()).toMatchInlineSnapshot(`null`);
571+
expect(console.warn.mock.calls).toMatchInlineSnapshot(`
572+
Array [
573+
Array [
574+
"[@fluent/react] Error: The id \\"foo\\" did not match any messages in the localization bundles.",
575+
],
576+
]
577+
`);
553578
});
554579
});

fluent-react/test/localized_valid.test.js

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,48 +5,70 @@ import {
55
LocalizationProvider,
66
Localized
77
} from "../esm/index";
8+
import { FluentBundle, FluentResource } from "@fluent/bundle";
89

910
describe("Localized - validation", () => {
10-
test("inside of a LocalizationProvider", () => {
11+
function createValidBundle() {
12+
const validBundle = new FluentBundle();
13+
validBundle.addResource(
14+
new FluentResource("example-id = Example localized text\n")
15+
);
16+
return validBundle;
17+
}
18+
19+
test("renders with no errors or warnings when correctly created inside of a LocalizationProvider", () => {
1120
const renderer = TestRenderer.create(
12-
<LocalizationProvider l10n={new ReactLocalization([])}>
13-
<Localized>
21+
<LocalizationProvider l10n={new ReactLocalization([createValidBundle()])}>
22+
<Localized id="example-id">
1423
<div />
1524
</Localized>
1625
</LocalizationProvider>
1726
);
1827

19-
expect(renderer.toJSON()).toMatchInlineSnapshot(`<div />`);
28+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
29+
<div>
30+
Example localized text
31+
</div>
32+
`);
2033
});
2134

22-
test("outside of a LocalizationProvider", () => {
23-
const renderer = TestRenderer.create(
24-
<Localized>
25-
<div />
26-
</Localized>
35+
test("throws an error when placed outside of a LocalizationProvider", () => {
36+
jest.spyOn(console, "error").mockImplementation(() => {});
37+
38+
expect(() => {
39+
TestRenderer.create(
40+
<Localized id="example-id">
41+
<div />
42+
</Localized>
43+
);
44+
}).toThrowErrorMatchingInlineSnapshot(
45+
`"The <Localized /> component was not properly wrapped in a <LocalizationProvider />."`
2746
);
2847

29-
expect(renderer.toJSON()).toMatchInlineSnapshot(`<div />`);
48+
// React also does a console.error.
49+
expect(console.error).toHaveBeenCalled();
3050
});
3151

32-
test("without a child", () => {
52+
test("renders the localized text when no child is provided", () => {
3353
const renderer = TestRenderer.create(
34-
<LocalizationProvider l10n={new ReactLocalization([])}>
35-
<Localized />
54+
<LocalizationProvider l10n={new ReactLocalization([createValidBundle()])}>
55+
<Localized id="example-id" />
3656
</LocalizationProvider>
3757
);
3858

39-
expect(renderer.toJSON()).toMatchInlineSnapshot(`null`);
59+
expect(renderer.toJSON()).toMatchInlineSnapshot(`"Example localized text"`);
4060
});
4161

42-
test("with multiple children", () => {
62+
test("throws when multiple children are provided", () => {
4363
// React also does a console.error, ignore that here.
4464
jest.spyOn(console, "error").mockImplementation(() => {});
4565

4666
expect(() => {
4767
TestRenderer.create(
48-
<LocalizationProvider l10n={new ReactLocalization([])}>
49-
<Localized>
68+
<LocalizationProvider
69+
l10n={new ReactLocalization([createValidBundle()])}
70+
>
71+
<Localized id="example-id">
5072
<div />
5173
<div />
5274
</Localized>
@@ -57,17 +79,23 @@ describe("Localized - validation", () => {
5779
expect(console.error).toHaveBeenCalled();
5880
});
5981

60-
test("with single children inside an array", () => {
82+
test("is valid to have a children array with a single element inside", () => {
6183
const renderer = TestRenderer.create(
62-
<LocalizationProvider l10n={new ReactLocalization([])}>
63-
<Localized children={[<div />]} />
84+
<LocalizationProvider l10n={new ReactLocalization([createValidBundle()])}>
85+
<Localized id="example-id" children={[<div />]} />
6486
</LocalizationProvider>
6587
);
6688

67-
expect(renderer.toJSON()).toMatchInlineSnapshot(`<div />`);
89+
expect(renderer.toJSON()).toMatchInlineSnapshot(`
90+
<div>
91+
Example localized text
92+
</div>
93+
`);
6894
});
6995

70-
test("without id", () => {
96+
test("has a warning when no id is provided", () => {
97+
jest.spyOn(console, "warn").mockImplementation(() => {});
98+
7199
const renderer = TestRenderer.create(
72100
<LocalizationProvider l10n={new ReactLocalization([])}>
73101
<Localized>
@@ -77,5 +105,13 @@ describe("Localized - validation", () => {
77105
);
78106

79107
expect(renderer.toJSON()).toMatchInlineSnapshot(`<div />`);
108+
109+
expect(console.warn.mock.calls).toMatchInlineSnapshot(`
110+
Array [
111+
Array [
112+
"[@fluent/react] Error: No id was provided for a <Localized /> component.",
113+
],
114+
]
115+
`);
80116
});
81117
});

0 commit comments

Comments
 (0)