Unverified Commit 81871feb authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #2209 from hypothesis/fix-single-annotation-load

Rectify infinite load on single-annotation, stream views
parents 737f919e 8bfec058
...@@ -12,13 +12,10 @@ import SidebarContentError from './sidebar-content-error'; ...@@ -12,13 +12,10 @@ import SidebarContentError from './sidebar-content-error';
* The main content for the single annotation page (aka. https://hypothes.is/a/<annotation ID>) * The main content for the single annotation page (aka. https://hypothes.is/a/<annotation ID>)
*/ */
function AnnotationViewerContent({ function AnnotationViewerContent({
api, loadAnnotationsService,
onLogin, onLogin,
rootThread: rootThreadService, rootThread: rootThreadService,
streamer,
streamFilter,
}) { }) {
const addAnnotations = useStore(store => store.addAnnotations);
const annotationId = useStore(store => store.routeParams().id); const annotationId = useStore(store => store.routeParams().id);
const clearAnnotations = useStore(store => store.clearAnnotations); const clearAnnotations = useStore(store => store.clearAnnotations);
const highlightAnnotations = useStore(store => store.highlightAnnotations); const highlightAnnotations = useStore(store => store.highlightAnnotations);
...@@ -34,13 +31,12 @@ function AnnotationViewerContent({ ...@@ -34,13 +31,12 @@ function AnnotationViewerContent({
setFetchError(false); setFetchError(false);
clearAnnotations(); clearAnnotations();
fetchThread(api, annotationId) loadAnnotationsService
.loadThread(annotationId)
.then(annots => { .then(annots => {
addAnnotations(annots);
// Find the top-level annotation in the thread that `annotationId` is // Find the top-level annotation in the thread that `annotationId` is
// part of. This will be different to `annotationId` if `annotationId` // part of. This will be different to `annotationId` if `annotationId`
// is a reply. // is a reply. A top-level annotation will not have any references.
const topLevelAnnot = annots.filter( const topLevelAnnot = annots.filter(
ann => (ann.references || []).length === 0 ann => (ann.references || []).length === 0
)[0]; )[0];
...@@ -59,14 +55,6 @@ function AnnotationViewerContent({ ...@@ -59,14 +55,6 @@ function AnnotationViewerContent({
return; return;
} }
// Configure the connection to the real-time update service to send us
// updates to any of the annotations in the thread.
streamFilter
.addClause('/references', 'one_of', topLevelAnnot.id, true)
.addClause('/id', 'equals', topLevelAnnot.id, true);
streamer.setConfig('filter', { filter: streamFilter.getFilter() });
streamer.connect();
// Make the full thread of annotations visible. By default replies are // Make the full thread of annotations visible. By default replies are
// not shown until the user expands the thread. // not shown until the user expands the thread.
annots.forEach(annot => setCollapsed(annot.id, false)); annots.forEach(annot => setCollapsed(annot.id, false));
...@@ -88,13 +76,10 @@ function AnnotationViewerContent({ ...@@ -88,13 +76,10 @@ function AnnotationViewerContent({
userid, userid,
// Static dependencies. // Static dependencies.
addAnnotations,
api,
clearAnnotations, clearAnnotations,
highlightAnnotations, highlightAnnotations,
loadAnnotationsService,
setCollapsed, setCollapsed,
streamFilter,
streamer,
]); ]);
return ( return (
...@@ -114,44 +99,13 @@ AnnotationViewerContent.propTypes = { ...@@ -114,44 +99,13 @@ AnnotationViewerContent.propTypes = {
onLogin: propTypes.func.isRequired, onLogin: propTypes.func.isRequired,
// Injected. // Injected.
api: propTypes.object, loadAnnotationsService: propTypes.object,
rootThread: propTypes.object, rootThread: propTypes.object,
streamer: propTypes.object,
streamFilter: propTypes.object,
}; };
AnnotationViewerContent.injectedProps = [ AnnotationViewerContent.injectedProps = [
'api', 'loadAnnotationsService',
'rootThread', 'rootThread',
'streamer',
'streamFilter',
]; ];
// NOTE: The function below is intentionally at the bottom of the file.
//
// Putting it at the top resulted in an issue where the `createElement` import
// wasn't correctly referenced in the body of `AnnotationViewerContent` in
// the compiled JS, causing a runtime error.
/**
* Fetch all annotations in the same thread as `id`.
*
* @param {Object} api - API client
* @param {string} id - Annotation ID. This may be an annotation or a reply.
* @return Promise<Annotation[]> - The annotation, followed by any replies.
*/
async function fetchThread(api, id) {
let annot = await api.annotation.get({ id });
if (annot.references && annot.references.length) {
// This is a reply, fetch the top-level annotation
annot = await api.annotation.get({ id: annot.references[0] });
}
// Fetch all replies to the top-level annotation.
const replySearchResult = await api.search({ references: annot.id });
return [annot, ...replySearchResult.rows];
}
export default withServices(AnnotationViewerContent); export default withServices(AnnotationViewerContent);
...@@ -17,6 +17,12 @@ function StreamContent({ ...@@ -17,6 +17,12 @@ function StreamContent({
toastMessenger, toastMessenger,
}) { }) {
const addAnnotations = useStore(store => store.addAnnotations); const addAnnotations = useStore(store => store.addAnnotations);
const annotationFetchStarted = useStore(
store => store.annotationFetchStarted
);
const annotationFetchFinished = useStore(
store => store.annotationFetchFinished
);
const clearAnnotations = useStore(store => store.clearAnnotations); const clearAnnotations = useStore(store => store.clearAnnotations);
const currentQuery = useStore(store => store.routeParams().q); const currentQuery = useStore(store => store.routeParams().q);
const setSortKey = useStore(store => store.setSortKey); const setSortKey = useStore(store => store.setSortKey);
...@@ -38,10 +44,21 @@ function StreamContent({ ...@@ -38,10 +44,21 @@ function StreamContent({
...searchFilter.toObject(query), ...searchFilter.toObject(query),
}; };
const results = await api.search(queryParams); try {
addAnnotations([...results.rows, ...results.replies]); annotationFetchStarted();
const results = await api.search(queryParams);
addAnnotations([...results.rows, ...results.replies]);
} finally {
annotationFetchFinished();
}
}, },
[addAnnotations, api, searchFilter] [
addAnnotations,
annotationFetchStarted,
annotationFetchFinished,
api,
searchFilter,
]
); );
// Update the stream when this route is initially displayed and whenever // Update the stream when this route is initially displayed and whenever
......
...@@ -8,38 +8,14 @@ import AnnotationViewerContent, { ...@@ -8,38 +8,14 @@ import AnnotationViewerContent, {
$imports, $imports,
} from '../annotation-viewer-content'; } from '../annotation-viewer-content';
/**
* Fake implementation of the `api` service.
*/
class FakeApi {
constructor(annots) {
this.annotations = annots;
this.annotation = {
get: async query => this.annotations.find(a => a.id === query.id),
};
}
async search(query) {
let matches = [];
if (query.references) {
matches = this.annotations.filter(
a => a.references && a.references.includes(query.references)
);
}
return { rows: matches };
}
}
describe('AnnotationViewerContent', () => { describe('AnnotationViewerContent', () => {
let fakeStore; let fakeStore;
let fakeOnLogin;
let fakeRootThread; let fakeRootThread;
let fakeStreamer; let fakeLoadAnnotationsService;
let fakeStreamFilter;
beforeEach(() => { beforeEach(() => {
fakeStore = { fakeStore = {
addAnnotations: sinon.stub(),
clearAnnotations: sinon.stub(), clearAnnotations: sinon.stub(),
getState: sinon.stub().returns({}), getState: sinon.stub().returns({}),
highlightAnnotations: sinon.stub(), highlightAnnotations: sinon.stub(),
...@@ -48,21 +24,13 @@ describe('AnnotationViewerContent', () => { ...@@ -48,21 +24,13 @@ describe('AnnotationViewerContent', () => {
setCollapsed: sinon.stub(), setCollapsed: sinon.stub(),
}; };
fakeRootThread = { thread: sinon.stub().returns({}) }; fakeLoadAnnotationsService = {
loadThread: sinon.stub().resolves([]),
fakeStreamer = {
setConfig: () => {},
connect: () => {},
}; };
fakeStreamFilter = { fakeOnLogin = sinon.stub();
addClause: () => {
return { fakeRootThread = { thread: sinon.stub().returns({}) };
addClause: () => {},
};
},
getFilter: () => {},
};
$imports.$mock(mockImportedComponents()); $imports.$mock(mockImportedComponents());
$imports.$mock({ $imports.$mock({
...@@ -74,55 +42,54 @@ describe('AnnotationViewerContent', () => { ...@@ -74,55 +42,54 @@ describe('AnnotationViewerContent', () => {
$imports.$restore(); $imports.$restore();
}); });
function createComponent({ api, onLogin = sinon.stub() }) { function createComponent(props = {}) {
return mount( return mount(
<AnnotationViewerContent <AnnotationViewerContent
api={api} loadAnnotationsService={fakeLoadAnnotationsService}
onLogin={onLogin} onLogin={fakeOnLogin}
rootThread={fakeRootThread} rootThread={fakeRootThread}
streamer={fakeStreamer} {...props}
streamFilter={fakeStreamFilter}
/> />
); );
} }
function waitForAnnotationsToLoad() {
return waitFor(() => fakeStore.addAnnotations.called);
}
describe('the standalone view for a top-level annotation', () => { describe('the standalone view for a top-level annotation', () => {
it('loads the annotation and all replies', async () => { it('loads the annotation thread', () => {
const fakeApi = new FakeApi([ createComponent();
{ id: 'test_annotation_id' },
{ id: 'test_reply_id', references: ['test_annotation_id'] },
]);
createComponent({ api: fakeApi });
await waitForAnnotationsToLoad(); assert.calledOnce(fakeLoadAnnotationsService.loadThread);
assert.calledOnce(fakeStore.addAnnotations);
assert.calledWith(
fakeStore.addAnnotations,
sinon.match(fakeApi.annotations)
);
}); });
it('does not highlight any annotations', async () => { context('successfully-loaded annotation thread', () => {
const fakeApi = new FakeApi([ beforeEach(() => {
{ id: 'test_annotation_id' }, fakeLoadAnnotationsService.loadThread.resolves([
{ id: 'test_reply_id', references: ['test_annotation_id'] }, { id: 'test_annotation_id' },
]); { id: 'test_reply_id', references: ['test_annotation_id'] },
createComponent({ api: fakeApi }); ]);
});
await waitForAnnotationsToLoad(); it('does not highlight any annotations', async () => {
createComponent();
await new Promise(resolve => setTimeout(resolve, 0));
assert.notCalled(fakeStore.highlightAnnotations);
});
assert.notCalled(fakeStore.highlightAnnotations); it('expands the thread', async () => {
createComponent();
await new Promise(resolve => setTimeout(resolve, 0));
assert.calledWith(fakeStore.setCollapsed, 'test_annotation_id', false);
assert.calledWith(fakeStore.setCollapsed, 'test_reply_id', false);
});
}); });
it('shows an error if the annotation could not be fetched', async () => { it('shows an error if the annotation could not be fetched', async () => {
const fakeApi = new FakeApi([]); fakeLoadAnnotationsService.loadThread.rejects();
const onLogin = sinon.stub(); const onLogin = sinon.stub();
const wrapper = createComponent({ api: fakeApi, onLogin }); const wrapper = createComponent({ onLogin });
// Initially the annotation is not available to the user, so an error // Initially the annotation is not available to the user, so an error
// should be shown. // should be shown.
...@@ -138,57 +105,28 @@ describe('AnnotationViewerContent', () => { ...@@ -138,57 +105,28 @@ describe('AnnotationViewerContent', () => {
onLoginRequest(); onLoginRequest();
assert.called(onLogin); assert.called(onLogin);
// After the user logs in, the annotation should be shown. fakeLoadAnnotationsService.loadThread.resetHistory();
fakeApi.annotations = [{ id: 'test_annotation_id' }]; fakeLoadAnnotationsService.loadThread.resolves([
{ id: 'test_annotation_id' },
]);
fakeStore.profile.returns({ userid: 'acct:jimsmith@hypothes.is' }); fakeStore.profile.returns({ userid: 'acct:jimsmith@hypothes.is' });
// Force re-render. `useStore` would do this in the actual app. // Force re-render. `useStore` would do this in the actual app.
wrapper.setProps({}); wrapper.setProps({});
await waitForAnnotationsToLoad(); await waitFor(() => fakeLoadAnnotationsService.loadThread.called);
assert.isFalse(wrapper.exists('SidebarContentError')); assert.isFalse(wrapper.exists('SidebarContentError'));
}); });
});
describe('the standalone view for a reply', () => {
it('loads the top-level annotation and all replies', async () => {
const fakeApi = new FakeApi([
{ id: 'parent_id' },
{ id: 'test_annotation_id', references: ['parent_id'] },
]);
createComponent({ api: fakeApi });
await waitForAnnotationsToLoad();
assert.calledWith(
fakeStore.addAnnotations,
sinon.match(fakeApi.annotations)
);
});
it('expands the thread', async () => {
const fakeApi = new FakeApi([
{ id: 'parent_id' },
{ id: 'test_annotation_id', references: ['parent_id'] },
]);
createComponent({ api: fakeApi });
await waitForAnnotationsToLoad();
assert.calledWith(fakeStore.setCollapsed, 'parent_id', false);
assert.calledWith(fakeStore.setCollapsed, 'test_annotation_id', false);
});
it('highlights the reply', async () => { it('highlights the annotation if it is a reply', async () => {
const fakeApi = new FakeApi([ fakeLoadAnnotationsService.loadThread.resolves([
{ id: 'parent_id' }, { id: 'parent_id' },
{ id: 'test_annotation_id', references: ['parent_id'] }, { id: 'test_annotation_id', references: ['parent_id'] },
]); ]);
createComponent({ api: fakeApi });
await waitForAnnotationsToLoad(); createComponent();
await new Promise(resolve => setTimeout(resolve, 0));
assert.calledWith( assert.calledWith(
fakeStore.highlightAnnotations, fakeStore.highlightAnnotations,
sinon.match(['test_annotation_id']) sinon.match(['test_annotation_id'])
......
...@@ -28,6 +28,8 @@ describe('StreamContent', () => { ...@@ -28,6 +28,8 @@ describe('StreamContent', () => {
fakeStore = { fakeStore = {
addAnnotations: sinon.stub(), addAnnotations: sinon.stub(),
annotationFetchStarted: sinon.stub(),
annotationFetchFinished: sinon.stub(),
clearAnnotations: sinon.spy(), clearAnnotations: sinon.spy(),
getState: sinon.stub().returns({}), getState: sinon.stub().returns({}),
routeParams: sinon.stub().returns({ id: 'test' }), routeParams: sinon.stub().returns({ id: 'test' }),
...@@ -69,6 +71,21 @@ describe('StreamContent', () => { ...@@ -69,6 +71,21 @@ describe('StreamContent', () => {
assert.equal(fakeApi.search.firstCall.args[0]._separate_replies, true); assert.equal(fakeApi.search.firstCall.args[0]._separate_replies, true);
}); });
it('records the start and finish of the fetch request in the store', async () => {
createComponent();
await waitFor(() => fakeStore.annotationFetchStarted.calledOnce);
await waitFor(() => fakeStore.annotationFetchFinished.calledOnce);
});
it('records the finish of the fetch request on error', async () => {
fakeApi.search.throws();
createComponent();
await waitFor(() => fakeStore.annotationFetchStarted.calledOnce);
await waitFor(() => fakeStore.annotationFetchFinished.calledOnce);
});
it('loads the annotations and replies into the store', async () => { it('loads the annotations and replies into the store', async () => {
fakeApi.search.resolves({ fakeApi.search.resolves({
rows: ['annotation_1', 'annotation_2'], rows: ['annotation_1', 'annotation_2'],
......
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
*/ */
import SearchClient from '../search-client'; import SearchClient from '../search-client';
import { isReply } from '../util/annotation-metadata';
// @ngInject // @ngInject
export default function loadAnnotationsService( export default function loadAnnotationsService(
api, api,
...@@ -61,7 +63,56 @@ export default function loadAnnotationsService( ...@@ -61,7 +63,56 @@ export default function loadAnnotationsService(
searchClient.get({ uri: uris, group: groupId }); searchClient.get({ uri: uris, group: groupId });
} }
/**
* Fetch all annotations in the same thread as `id` and add them to the store.
*
* @param {string} id - Annotation ID. This may be an annotation or a reply.
* @return Promise<Annotation[]> - The annotation, followed by any replies.
*/
async function loadThread(id) {
let annotation;
let replySearchResult;
// Clear out any annotations already in the store before fetching new ones
store.clearAnnotations();
try {
store.annotationFetchStarted();
// 1. Fetch the annotation indicated by `id` — the target annotation
annotation = await api.annotation.get({ id });
// 2. If annotation is not the top-level annotation in its thread,
// fetch the top-level annotation
if (isReply(annotation)) {
annotation = await api.annotation.get({ id: annotation.references[0] });
}
// 3. Fetch all of the annotations in the thread, based on the
// top-level annotation
replySearchResult = await api.search({ references: annotation.id });
} finally {
store.annotationFetchFinished();
}
const threadAnnotations = [annotation, ...replySearchResult.rows];
store.addAnnotations(threadAnnotations);
// If we've been successful in retrieving a thread, with a top-level annotation,
// configure the connection to the real-time update service to send us
// updates to any of the annotations in the thread.
if (!isReply(annotation)) {
streamFilter
.addClause('/references', 'one_of', annotation.id, true)
.addClause('/id', 'equals', annotation.id, true);
streamer.setConfig('filter', { filter: streamFilter.getFilter() });
streamer.connect();
}
return threadAnnotations;
}
return { return {
load, load,
loadThread,
}; };
} }
...@@ -43,13 +43,17 @@ describe('loadAnnotationsService', () => { ...@@ -43,13 +43,17 @@ describe('loadAnnotationsService', () => {
longRunningSearchClient = false; longRunningSearchClient = false;
fakeApi = { fakeApi = {
search: sinon.stub(), search: sinon.stub().returns({ rows: [] }),
annotation: {
get: sinon.stub(),
},
}; };
fakeStore = { fakeStore = {
addAnnotations: sinon.stub(), addAnnotations: sinon.stub(),
annotationFetchFinished: sinon.stub(), annotationFetchFinished: sinon.stub(),
annotationFetchStarted: sinon.stub(), annotationFetchStarted: sinon.stub(),
clearAnnotations: sinon.stub(),
frames: sinon.stub(), frames: sinon.stub(),
removeAnnotations: sinon.stub(), removeAnnotations: sinon.stub(),
savedAnnotations: sinon.stub(), savedAnnotations: sinon.stub(),
...@@ -62,6 +66,9 @@ describe('loadAnnotationsService', () => { ...@@ -62,6 +66,9 @@ describe('loadAnnotationsService', () => {
reconnect: sinon.stub(), reconnect: sinon.stub(),
}; };
fakeStreamFilter = { fakeStreamFilter = {
addClause: sinon.stub().returns({
addClause: sinon.stub(),
}),
resetFilter: sinon.stub().returns({ resetFilter: sinon.stub().returns({
addClause: sinon.stub(), addClause: sinon.stub(),
}), }),
...@@ -257,4 +264,187 @@ describe('loadAnnotationsService', () => { ...@@ -257,4 +264,187 @@ describe('loadAnnotationsService', () => {
assert.calledWith(console.error, error); assert.calledWith(console.error, error);
}); });
}); });
describe('loadThread', () => {
let threadAnnotations = [
{ id: 'parent_annotation_1' },
{ id: 'parent_annotation_2', references: ['parent_annotation_1'] },
{
id: 'target_annotation',
references: ['parent_annotation_1', 'parent_annotation_2'],
},
];
it('clears annotations from the store first', () => {
const svc = createService();
svc.loadThread('target_annotation');
assert.calledOnce(fakeStore.clearAnnotations);
});
describe('fetching the target annotation', () => {
beforeEach(() => {
fakeApi.annotation.get.onFirstCall().resolves({
id: 'target_annotation',
references: [],
});
});
it('fetches annotation with given `id`', async () => {
const svc = createService();
await svc.loadThread('target_annotation');
assert.calledWith(
fakeApi.annotation.get,
sinon.match({ id: 'target_annotation' })
);
});
it('records the start and end of annotation fetch with the store', async () => {
const svc = createService();
await svc.loadThread('target_annotation');
assert.calledOnce(fakeStore.annotationFetchStarted);
assert.calledOnce(fakeStore.annotationFetchFinished);
});
it('stops the annotation fetch with the store on error', async () => {
fakeApi.annotation.get.onFirstCall().throws();
const svc = createService();
try {
await svc.loadThread('target_annotation');
} catch (e) {
assert.calledOnce(fakeStore.annotationFetchStarted);
assert.calledOnce(fakeStore.annotationFetchFinished);
}
});
});
describe('fetching top-level annotation in thread', () => {
beforeEach(() => {
fakeApi.annotation.get.onFirstCall().resolves({
id: 'target_annotation',
references: ['parent_annotation_1', 'parent_annotation_2'],
});
fakeApi.annotation.get.onSecondCall().resolves({
id: 'parent_annotation_1',
references: [],
});
});
it('fetches top-level annotation', async () => {
const svc = createService();
await svc.loadThread('target_annotation');
assert.calledWith(
fakeApi.annotation.get,
sinon.match({ id: 'parent_annotation_1' })
);
});
});
describe('fetching other annotations in the thread', () => {
beforeEach(() => {
fakeApi.annotation.get.onFirstCall().resolves({
id: 'target_annotation',
references: ['parent_annotation_1', 'parent_annotation_2'],
});
fakeApi.annotation.get.onSecondCall().resolves({
id: 'parent_annotation_1',
references: [],
});
fakeApi.search.resolves({
rows: [threadAnnotations[1], threadAnnotations[2]],
});
});
it('retrieves all annotations in the thread', async () => {
const svc = createService();
await svc.loadThread('target_annotation');
assert.calledWith(
fakeApi.search,
sinon.match({ references: 'parent_annotation_1' })
);
});
it('adds all of the annotations in the thread to the store', async () => {
const svc = createService();
await svc.loadThread('target_annotation');
assert.calledWith(fakeStore.addAnnotations, sinon.match.array);
});
it('returns thread annotations', async () => {
const svc = createService();
const annots = await svc.loadThread('target_annotation');
assert.equal(annots[0].id, 'parent_annotation_1');
assert.equal(annots[1].id, 'parent_annotation_2');
assert.equal(annots[2].id, 'target_annotation');
});
});
describe('connecting to streamer for thread updates', () => {
beforeEach(() => {
fakeApi.annotation.get.onFirstCall().resolves({
id: 'target_annotation',
references: ['parent_annotation_1', 'parent_annotation_2'],
});
fakeApi.annotation.get.onSecondCall().resolves({
id: 'parent_annotation_1',
references: [],
});
fakeApi.search.resolves({
rows: [threadAnnotations[1], threadAnnotations[2]],
});
});
it('does not connect to the streamer if no top-level annotation available', async () => {
// Make it so the "top-level" annotation isn't really top level: it has references
// and so is a reply
fakeApi.annotation.get.onSecondCall().resolves({
id: 'parent_annotation_1',
references: ['something_else'],
});
const svc = createService();
svc.loadThread('target_annotation');
await new Promise(resolve => setTimeout(resolve, 0));
assert.notCalled(fakeStreamer.connect);
});
it('configures the stream filter for changes to the thread', async () => {
const fakeAddClause = sinon.stub();
fakeStreamFilter.addClause.returns({ addClause: fakeAddClause });
fakeStreamFilter.getFilter.returns('filter');
const svc = createService();
svc.loadThread('target_annotation');
await new Promise(resolve => setTimeout(resolve, 0));
assert.calledWith(
fakeStreamFilter.addClause,
'/references',
'one_of',
'parent_annotation_1',
true
);
assert.calledWith(
fakeAddClause,
'/id',
'equals',
'parent_annotation_1',
true
);
assert.calledWith(
fakeStreamer.setConfig,
'filter',
sinon.match({ filter: 'filter' })
);
assert.calledOnce(fakeStreamer.connect);
});
});
});
}); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment