Skip to content

WIP ReactLocalization: Accept async iterables of contexts #223

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions fluent-react/examples/fallback-async/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
11 changes: 11 additions & 0 deletions fluent-react/examples/fallback-async/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fallback Async - a fluent-react example</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
28 changes: 28 additions & 0 deletions fluent-react/examples/fallback-async/src/App.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';
import { Localized } from 'fluent-react/compat';

export default function App() {
return (
<div>
<p>
<em>This example is hardcoded to use <code>['pl', 'en-US']</code>.</em>
</p>

<Localized id="foo">
<p>Foo</p>
</Localized>

<Localized id="bar">
<p>Bar</p>
</Localized>

<Localized id="baz">
<p>Baz is missing from all locales</p>
</Localized>

<Localized id="qux" $baz="Baz">
<p>{'Qux is like { $baz }: missing from all locales.'}</p>
</Localized>
</div>
);
}
14 changes: 14 additions & 0 deletions fluent-react/examples/fallback-async/src/index.js
Original file line number Diff line number Diff line change
@@ -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(
<LocalizationProvider messages={generateMessages()}>
<App />
</LocalizationProvider>,
document.getElementById('root')
);
45 changes: 45 additions & 0 deletions fluent-react/examples/fallback-async/src/l10n.js
Original file line number Diff line number Diff line change
@@ -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};
}
};
}
2 changes: 1 addition & 1 deletion fluent-react/makefile
Original file line number Diff line number Diff line change
@@ -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

Expand Down
102 changes: 102 additions & 0 deletions fluent-react/src/cached_async_iterable.js
Original file line number Diff line number Diff line change
@@ -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];
}
}
42 changes: 35 additions & 7 deletions fluent-react/src/localization.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
}

/*
Expand All @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion fluent-react/src/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}

Expand Down Expand Up @@ -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.`
);
Expand Down
13 changes: 9 additions & 4 deletions fluent-react/test/localized_change_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Loading