diff --git a/fluent-react/examples/fallback-async/package.json b/fluent-react/examples/fallback-async/package.json
new file mode 100644
index 000000000..357a34ba7
--- /dev/null
+++ b/fluent-react/examples/fallback-async/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "fluent-react-example-fallback-async",
+ "version": "0.1.0",
+ "private": true,
+ "devDependencies": {
+ "react-scripts": "1.1.0"
+ },
+ "dependencies": {
+ "babel-polyfill": "^6.26.0",
+ "fluent": "file:../../../fluent",
+ "fluent-intl-polyfill": "file:../../../fluent-intl-polyfill",
+ "fluent-react": "file:../../../fluent-react",
+ "react": "^16.2.0",
+ "react-dom": "^16.2.0"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build"
+ }
+}
diff --git a/fluent-react/examples/fallback-async/public/index.html b/fluent-react/examples/fallback-async/public/index.html
new file mode 100644
index 000000000..0e506c233
--- /dev/null
+++ b/fluent-react/examples/fallback-async/public/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ Fallback Async - a fluent-react example
+
+
+
+
+
diff --git a/fluent-react/examples/fallback-async/src/App.js b/fluent-react/examples/fallback-async/src/App.js
new file mode 100644
index 000000000..16fcec3ea
--- /dev/null
+++ b/fluent-react/examples/fallback-async/src/App.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import { Localized } from 'fluent-react/compat';
+
+export default function App() {
+ return (
+
+
+ This example is hardcoded to use ['pl', 'en-US']
.
+
+
+
+ Foo
+
+
+
+ Bar
+
+
+
+ Baz is missing from all locales
+
+
+
+ {'Qux is like { $baz }: missing from all locales.'}
+
+
+ );
+}
diff --git a/fluent-react/examples/fallback-async/src/index.js b/fluent-react/examples/fallback-async/src/index.js
new file mode 100644
index 000000000..63c002097
--- /dev/null
+++ b/fluent-react/examples/fallback-async/src/index.js
@@ -0,0 +1,14 @@
+import 'babel-polyfill';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { LocalizationProvider } from 'fluent-react/compat';
+
+import { generateMessages } from './l10n';
+import App from './App';
+
+ReactDOM.render(
+
+
+ ,
+ document.getElementById('root')
+);
diff --git a/fluent-react/examples/fallback-async/src/l10n.js b/fluent-react/examples/fallback-async/src/l10n.js
new file mode 100644
index 000000000..769cb7071
--- /dev/null
+++ b/fluent-react/examples/fallback-async/src/l10n.js
@@ -0,0 +1,45 @@
+import 'fluent-intl-polyfill/compat';
+import { MessageContext } from 'fluent/compat';
+
+const MESSAGES_ALL = {
+ 'pl': `
+foo = Foo po polsku
+ `,
+ 'en-US': `
+foo = Foo in English
+bar = Bar in English
+ `,
+};
+
+
+// create-react-app is rather outdated at this point when it comes to modern JS
+// support. It's not configured to understand async generators. The function
+// below is equivalent to the following generator function:
+//
+// export async function* generateMessages() {
+// for (const locale of ['pl', 'en-US']) {
+// const cx = new MessageContext(locale);
+// cx.addMessages(MESSAGES_ALL[locale]);
+// yield cx;
+// }
+// }
+
+export function generateMessages() {
+ const locales = ["pl", "en-US"];
+ return {
+ [Symbol.asyncIterator]() {
+ return this;
+ },
+ async next() {
+ const locale = locales.shift();
+
+ if (locale === undefined) {
+ return {value: undefined, done: true};
+ }
+
+ const cx = new MessageContext(locale);
+ cx.addMessages(MESSAGES_ALL[locale]);
+ return {value: cx, done: false};
+ }
+ };
+}
diff --git a/fluent-react/makefile b/fluent-react/makefile
index c0f6470a3..78f085298 100644
--- a/fluent-react/makefile
+++ b/fluent-react/makefile
@@ -1,6 +1,6 @@
PACKAGE := fluent-react
GLOBAL := FluentReact
-DEPS := fluent:Fluent,react:React,prop-types:PropTypes
+DEPS := fluent:Fluent,cached-iterable:CachedIterable,react:React,prop-types:PropTypes
include ../common.mk
diff --git a/fluent-react/src/cached_async_iterable.js b/fluent-react/src/cached_async_iterable.js
new file mode 100644
index 000000000..381515eb7
--- /dev/null
+++ b/fluent-react/src/cached_async_iterable.js
@@ -0,0 +1,102 @@
+/*
+ * CachedAsyncIterable caches the elements yielded by an async iterable.
+ *
+ * It can be used to iterate over an iterable many times without depleting the
+ * iterable.
+ */
+export default class CachedAsyncIterable {
+ /**
+ * Create an `CachedAsyncIterable` instance.
+ *
+ * @param {Iterable} iterable
+ * @returns {CachedAsyncIterable}
+ */
+ constructor(iterable) {
+ if (Symbol.asyncIterator in Object(iterable)) {
+ this.iterator = iterable[Symbol.asyncIterator]();
+ } else if (Symbol.iterator in Object(iterable)) {
+ this.iterator = iterable[Symbol.iterator]();
+ } else {
+ throw new TypeError("Argument must implement the iteration protocol.");
+ }
+
+ this.seen = [];
+ }
+
+ /**
+ * Create an `CachedAsyncIterable` instance from an iterable or, if
+ * another instance of `CachedAsyncIterable` is passed, return it
+ * without any modifications.
+ *
+ * @param {Iterable} iterable
+ * @returns {CachedAsyncIterable}
+ */
+ static from(iterable) {
+ if (iterable instanceof CachedAsyncIterable) {
+ return iterable;
+ }
+
+ return new CachedAsyncIterable(iterable);
+ }
+
+ /**
+ * Synchronous iterator over the cached elements.
+ *
+ * Return a generator object implementing the iterator protocol over the
+ * cached elements of the original (async or sync) iterable.
+ */
+ [Symbol.iterator]() {
+ const {seen} = this;
+ let cur = 0;
+
+ return {
+ next() {
+ if (seen.length === cur) {
+ return {value: undefined, done: true};
+ }
+ return seen[cur++];
+ }
+ };
+ }
+
+ /**
+ * Asynchronous iterator caching the yielded elements.
+ *
+ * Elements yielded by the original iterable will be cached and available
+ * synchronously. Returns an async generator object implementing the
+ * iterator protocol over the elements of the original (async or sync)
+ * iterable.
+ */
+ [Symbol.asyncIterator]() {
+ const { seen, iterator } = this;
+ let cur = 0;
+
+ return {
+ async next() {
+ if (seen.length <= cur) {
+ seen.push(await iterator.next());
+ }
+ return seen[cur++];
+ }
+ };
+ }
+
+ /**
+ * This method allows user to consume the next element from the iterator
+ * into the cache.
+ *
+ * @param {number} count - number of elements to consume
+ */
+ async touchNext(count = 1) {
+ const { seen, iterator } = this;
+ let idx = 0;
+ while (idx++ < count) {
+ if (seen.length === 0 || seen[seen.length - 1].done === false) {
+ seen.push(await iterator.next());
+ }
+ }
+ // Return the last cached {value, done} object to allow the calling
+ // code to decide if it needs to call touchNext again.
+ return seen[seen.length - 1];
+ }
+}
diff --git a/fluent-react/src/localization.js b/fluent-react/src/localization.js
index 70089486d..f99e608ee 100644
--- a/fluent-react/src/localization.js
+++ b/fluent-react/src/localization.js
@@ -1,4 +1,5 @@
-import { CachedIterable, mapContextSync } from "fluent";
+import {mapContextSync} from "fluent";
+import CachedAsyncIterable from "./cached_async_iterable";
/*
* `ReactLocalization` handles translation formatting and fallback.
@@ -17,8 +18,10 @@ import { CachedIterable, mapContextSync } from "fluent";
*/
export default class ReactLocalization {
constructor(messages) {
- this.contexts = new CachedIterable(messages);
+ this.contexts = CachedAsyncIterable.from(messages);
this.subs = new Set();
+
+ this.isFetching = false;
}
/*
@@ -39,14 +42,39 @@ export default class ReactLocalization {
* Set a new `messages` iterable and trigger the retranslation.
*/
setMessages(messages) {
- this.contexts = new CachedIterable(messages);
-
- // Update all subscribed Localized components.
- this.subs.forEach(comp => comp.relocalize());
+ this.contexts = CachedAsyncIterable.from(messages);
+ if (this.contexts.length === 0) {
+ // If the iterable has an empty cache, request the first context but do
+ // not await it.
+ this.touchNext(1);
+ } else {
+ // Otherwise, let's try to use the new cached contexts. Update all
+ // subscribed Localized components.
+ this.subs.forEach(comp => comp.relocalize());
+ }
}
getMessageContext(id) {
- return mapContextSync(this.contexts, id);
+ const mcx = mapContextSync(this.contexts, id);
+ if (mcx === null && !this.isFetching) {
+ // Request the next context but do not await it.
+ this.touchNext(1);
+ }
+ return mcx;
+ }
+
+ async touchNext(count = 1) {
+ // Fetch the first `count` contexts.
+ this.isFetching = true;
+ const last = await this.contexts.touchNext(count);
+ this.isFetching = false;
+
+ if (!last.done) {
+ // If the iterable of contexts has not been exhausted yet, the newly
+ // fetched context might offer fallback translations. Update all
+ // subscribed Localized components.
+ this.subs.forEach(comp => comp.relocalize());
+ }
}
formatCompound(mcx, msg, args) {
diff --git a/fluent-react/src/provider.js b/fluent-react/src/provider.js
index 53cfd8b84..cfeafffb3 100644
--- a/fluent-react/src/provider.js
+++ b/fluent-react/src/provider.js
@@ -30,7 +30,7 @@ export default class LocalizationProvider extends Component {
throw new Error("LocalizationProvider must receive the messages prop.");
}
- if (!messages[Symbol.iterator]) {
+ if (!messages[Symbol.iterator] && !messages[Symbol.asyncIterator]) {
throw new Error("The messages prop must be an iterable.");
}
@@ -72,6 +72,10 @@ function isIterable(props, propName, componentName) {
return null;
}
+ if (Symbol.asyncIterator in Object(prop)) {
+ return null;
+ }
+
return new Error(
`The ${propName} prop supplied to ${componentName} must be an iterable.`
);
diff --git a/fluent-react/test/localized_change_test.js b/fluent-react/test/localized_change_test.js
index 9d997728b..9b8134ff2 100644
--- a/fluent-react/test/localized_change_test.js
+++ b/fluent-react/test/localized_change_test.js
@@ -3,12 +3,15 @@ import assert from 'assert';
import { shallow } from 'enzyme';
import { MessageContext } from '../../fluent/src';
import ReactLocalization from '../src/localization';
+import CachedAsyncIterable from '../src/cached_async_iterable';
import { Localized } from '../src/index';
-suite('Localized - change messages', function() {
- test('relocalizing', function() {
+suite.only('Localized - change messages', function() {
+ test('relocalizing', async function() {
const mcx1 = new MessageContext();
- const l10n = new ReactLocalization([mcx1]);
+ const contexts1 = new CachedAsyncIterable([mcx1]);
+ await contexts1.touchNext(1);
+ const l10n = new ReactLocalization(contexts1);
mcx1.addMessages(`
foo = FOO
@@ -30,7 +33,9 @@ foo = FOO
foo = BAR
`);
- l10n.setMessages([mcx2]);
+ const contexts2 = new CachedAsyncIterable([mcx2]);
+ await contexts2.touchNext(1);
+ l10n.setMessages(contexts2);
wrapper.update();
assert.ok(wrapper.contains(
diff --git a/fluent-react/test/localized_render_test.js b/fluent-react/test/localized_render_test.js
index 7ccb25e9f..a968dfb03 100644
--- a/fluent-react/test/localized_render_test.js
+++ b/fluent-react/test/localized_render_test.js
@@ -4,13 +4,20 @@ import sinon from 'sinon';
import { shallow } from 'enzyme';
import { MessageContext } from '../../fluent/src';
import ReactLocalization from '../src/localization';
+import CachedAsyncIterable from '../src/cached_async_iterable';
import { Localized } from '../src/index';
suite('Localized - rendering', function() {
- test('render the value', function() {
- const mcx = new MessageContext();
- const l10n = new ReactLocalization([mcx]);
+ let mcx, l10n;
+ setup(async function() {
+ mcx = new MessageContext();
+ const contexts = new CachedAsyncIterable([mcx]);
+ await contexts.touchNext(1);
+ l10n = new ReactLocalization(contexts);
+ });
+
+ test('render the value', async function() {
mcx.addMessages(`
foo = FOO
`)
@@ -28,9 +35,6 @@ foo = FOO
});
test('render an allowed attribute', function() {
- const mcx = new MessageContext();
- const l10n = new ReactLocalization([mcx]);
-
mcx.addMessages(`
foo =
.attr = ATTR
@@ -49,9 +53,6 @@ foo =
});
test('only render allowed attributes', function() {
- const mcx = new MessageContext();
- const l10n = new ReactLocalization([mcx]);
-
mcx.addMessages(`
foo =
.attr1 = ATTR 1
@@ -71,9 +72,6 @@ foo =
});
test('filter out forbidden attributes', function() {
- const mcx = new MessageContext();
- const l10n = new ReactLocalization([mcx]);
-
mcx.addMessages(`
foo =
.attr = ATTR
@@ -92,9 +90,6 @@ foo =
});
test('filter all attributes if attrs not given', function() {
- const mcx = new MessageContext();
- const l10n = new ReactLocalization([mcx]);
-
mcx.addMessages(`
foo =
.attr = ATTR
@@ -113,9 +108,6 @@ foo =
});
test('preserve existing attributes when setting new ones', function() {
- const mcx = new MessageContext();
- const l10n = new ReactLocalization([mcx]);
-
mcx.addMessages(`
foo =
.attr = ATTR
@@ -134,9 +126,6 @@ foo =
});
test('overwrite existing attributes if allowed', function() {
- const mcx = new MessageContext();
- const l10n = new ReactLocalization([mcx]);
-
mcx.addMessages(`
foo =
.existing = ATTR
@@ -155,9 +144,6 @@ foo =
});
test('protect existing attributes if setting is forbidden', function() {
- const mcx = new MessageContext();
- const l10n = new ReactLocalization([mcx]);
-
mcx.addMessages(`
foo =
.existing = ATTR
@@ -176,9 +162,6 @@ foo =
});
test('protect existing attributes by default', function() {
- const mcx = new MessageContext();
- const l10n = new ReactLocalization([mcx]);
-
mcx.addMessages(`
foo =
.existing = ATTR
@@ -197,9 +180,6 @@ foo =
});
test('preserve children when translation value is null', function() {
- const mcx = new MessageContext();
- const l10n = new ReactLocalization([mcx]);
-
mcx.addMessages(`
foo =
.title = TITLE
@@ -223,9 +203,7 @@ foo =
test('$arg is passed to format the value', function() {
- const mcx = new MessageContext();
const format = sinon.spy(mcx, 'format');
- const l10n = new ReactLocalization([mcx]);
mcx.addMessages(`
foo = { $arg }
@@ -243,9 +221,7 @@ foo = { $arg }
});
test('$arg is passed to format the attributes', function() {
- const mcx = new MessageContext();
const format = sinon.spy(mcx, 'format');
- const l10n = new ReactLocalization([mcx]);
mcx.addMessages(`
foo = { $arg }