Skip to content

Commit 383ecc3

Browse files
authored
fix(frontend): preserve created feeds when preview loading fails (#915)
## Summary - preserve created feed result when preview loading fails - move preview hydration into conversion flow so creation success is not blocked by preview fetch - update UI state copy to match the new creation/preview lifecycle ## Validation - make ready - frontend smoke at http://127.0.0.1:4001/ (create + token-required states)
1 parent f558689 commit 383ecc3

13 files changed

Lines changed: 400 additions & 209 deletions

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ gem 'puma', require: false
2323

2424
group :development do
2525
gem 'byebug'
26+
gem 'irb', require: false
2627
gem 'rake', require: false
2728
gem 'rubocop', require: false
2829
gem 'rubocop-performance', require: false

Gemfile.lock

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ GEM
106106
bigdecimal
107107
rexml
108108
crass (1.0.6)
109+
date (3.5.1)
109110
diff-lcs (1.6.2)
110111
docile (1.4.1)
111112
drb (2.2.3)
@@ -144,6 +145,7 @@ GEM
144145
dry-initializer (~> 3.2)
145146
dry-schema (~> 1.14)
146147
zeitwerk (~> 2.6)
148+
erb (6.0.2)
147149
erubi (1.13.1)
148150
faraday (2.14.1)
149151
faraday-net_http (>= 2.0, < 3.5)
@@ -167,6 +169,11 @@ GEM
167169
io-endpoint (0.17.2)
168170
io-event (1.14.5)
169171
io-stream (0.11.1)
172+
irb (1.17.0)
173+
pp (>= 0.6.0)
174+
prism (>= 1.3.0)
175+
rdoc (>= 4.0.0)
176+
reline (>= 0.4.2)
170177
json (2.19.3)
171178
json-schema (6.2.0)
172179
addressable (~> 2.8)
@@ -212,6 +219,9 @@ GEM
212219
parser (3.3.10.2)
213220
ast (~> 2.4.1)
214221
racc
222+
pp (0.6.3)
223+
prettyprint
224+
prettyprint (0.2.0)
215225
prism (1.9.0)
216226
protocol-hpack (1.5.1)
217227
protocol-http (0.60.0)
@@ -227,6 +237,9 @@ GEM
227237
protocol-url (0.4.0)
228238
protocol-websocket (0.20.2)
229239
protocol-http (~> 0.2)
240+
psych (5.3.1)
241+
date
242+
stringio
230243
public_suffix (7.0.5)
231244
puma (7.2.0)
232245
nio4r (~> 2.0)
@@ -258,6 +271,10 @@ GEM
258271
rbs (3.10.3)
259272
logger
260273
tsort
274+
rdoc (7.2.0)
275+
erb
276+
psych (>= 4.0.0)
277+
tsort
261278
regexp_parser (2.11.3)
262279
reline (0.6.3)
263280
io-console (~> 0.5)
@@ -335,6 +352,7 @@ GEM
335352
simplecov_json_formatter (0.1.4)
336353
ssrf_filter (1.3.0)
337354
stackprof (0.2.28)
355+
stringio (3.2.0)
338356
thor (1.5.0)
339357
traces (0.18.2)
340358
tsort (0.2.0)
@@ -369,6 +387,7 @@ DEPENDENCIES
369387
climate_control
370388
html2rss!
371389
html2rss-configs!
390+
irb
372391
parallel
373392
puma
374393
rack-cache
@@ -414,6 +433,7 @@ CHECKSUMS
414433
console (1.34.3) sha256=869fbd74697efc4c606f102d2812b0b008e4e7fd738a91c591e8577140ec0dcc
415434
crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
416435
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
436+
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
417437
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
418438
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
419439
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
@@ -425,6 +445,7 @@ CHECKSUMS
425445
dry-schema (1.16.0) sha256=cd3aaeabc0f1af66ec82a29096d4c4fb92a0a58b9dae29a22b1bbceb78985727
426446
dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178
427447
dry-validation (1.11.1) sha256=70900bb5a2d911c8aab566d3e360c6bff389b8bf92ea8e04885ce51c41ff8085
448+
erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b
428449
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
429450
faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c
430451
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
@@ -441,6 +462,7 @@ CHECKSUMS
441462
io-endpoint (0.17.2) sha256=3feaf766c116b35839c11fac68b6aaadc47887bb488902a57bf8e1d288fb3338
442463
io-event (1.14.5) sha256=68ac367032a3873416dc2e0b67332dfaf2e23b65b58e6465d301c7e5cd9163b1
443464
io-stream (0.11.1) sha256=fa5f551fcff99581c1757b9d1cee2c37b124f07d2ca4f40b756a05ab9bd21b87
465+
irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae
444466
json (2.19.3) sha256=289b0bb53052a1fa8c34ab33cc750b659ba14a5c45f3fcf4b18762dc67c78646
445467
json-schema (6.2.0) sha256=e8bff46ed845a22c1ab2bd0d7eccf831c01fe23bb3920caa4c74db4306813666
446468
kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa
@@ -465,6 +487,8 @@ CHECKSUMS
465487
nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8
466488
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
467489
parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357
490+
pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
491+
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
468492
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
469493
protocol-hpack (1.5.1) sha256=6feca238b8078da1cd295677d6f306c6001af92d75fe0643d33e6956cbc3ad91
470494
protocol-http (0.60.0) sha256=ca1354947676d663b6f23c49654aee464288774e7867c4a6e406fecce9691cec
@@ -473,6 +497,7 @@ CHECKSUMS
473497
protocol-rack (0.22.0) sha256=b7c49c0b597ca2c6d20f8bcd746c4415a1b750eacfbe64f828e780c978a4293d
474498
protocol-url (0.4.0) sha256=64d4c03b6b51ad815ac6fdaf77a1d91e5baf9220d26becb846c5459dacdea9e1
475499
protocol-websocket (0.20.2) sha256=c41d93c35fba5dae85375c597f76975f3dbd75d8c5b2f21b33dab4dc22a5a511
500+
psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
476501
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
477502
puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8
478503
puppeteer-ruby (0.51.0) sha256=8a7637963f8cd5b88416dd8c669a3ec2fe40a42cda2449539d75525a4da2f233
@@ -487,6 +512,7 @@ CHECKSUMS
487512
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
488513
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
489514
rbs (3.10.3) sha256=70627f3919016134d554e6c99195552ae3ef6020fe034c8e983facc9c192daa6
515+
rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
490516
regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
491517
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
492518
reverse_markdown (3.0.2) sha256=818ebb92ce39dbb1a291690dd1ec9a6d62530d4725296b17e9c8f668f9a5b8af
@@ -515,6 +541,7 @@ CHECKSUMS
515541
simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
516542
ssrf_filter (1.3.0) sha256=66882d7de7d09c019098d6d7372412950ae184ebbc7c51478002058307aba6f2
517543
stackprof (0.2.28) sha256=4ec2ace02f386012b40ca20ef80c030ad711831f59511da12e83b34efb0f9a04
544+
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
518545
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
519546
traces (0.18.2) sha256=80f1649cb4daace1d7174b81f3b3b7427af0b93047759ba349960cb8f315e214
520547
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f

frontend/src/__tests__/App.contract.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('App contract', () => {
3030
})
3131
);
3232
}),
33-
http.get('/api/v1/feeds/generated-token', ({ request }) => {
33+
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
3434
expect(request.headers.get('accept')).toBe('application/feed+json');
3535

3636
return HttpResponse.json(

frontend/src/__tests__/App.test.tsx

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -206,21 +206,22 @@ describe('App', () => {
206206
});
207207

208208
it('renders the result panel when a feed is available', async () => {
209-
vi.spyOn(window, 'fetch').mockResolvedValue({
210-
ok: true,
211-
json: async () => ({ items: [] }),
212-
} as Response);
213-
214209
mockUseFeedConversion.mockReturnValue({
215210
isConverting: false,
216211
result: {
217-
id: 'feed-123',
218-
name: 'Example Feed',
219-
url: 'https://example.com/articles',
220-
strategy: 'faraday',
221-
feed_token: 'example-token',
222-
public_url: '/api/v1/feeds/example-token',
223-
json_public_url: '/api/v1/feeds/example-token.json',
212+
feed: {
213+
id: 'feed-123',
214+
name: 'Example Feed',
215+
url: 'https://example.com/articles',
216+
strategy: 'faraday',
217+
feed_token: 'example-token',
218+
public_url: '/api/v1/feeds/example-token',
219+
json_public_url: '/api/v1/feeds/example-token.json',
220+
},
221+
preview: {
222+
items: [],
223+
error: 'Preview unavailable right now.',
224+
},
224225
},
225226
error: null,
226227
convertFeed: mockConvertFeed,
@@ -233,6 +234,7 @@ describe('App', () => {
233234
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
234235
expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument();
235236
expect(screen.getByText('Example Feed')).toBeInTheDocument();
237+
expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument();
236238
});
237239

238240
it('surfaces conversion errors to the user', () => {
@@ -250,6 +252,24 @@ describe('App', () => {
250252
expect(screen.getByText('Access denied')).toBeInTheDocument();
251253
});
252254

255+
it('shows an explicit loading notice while feed creation is still resolving preview state', () => {
256+
mockUseFeedConversion.mockReturnValue({
257+
isConverting: true,
258+
result: null,
259+
error: null,
260+
convertFeed: mockConvertFeed,
261+
clearError: mockClearConversionError,
262+
clearResult: mockClearResult,
263+
});
264+
265+
render(<App />);
266+
267+
expect(screen.getByText('Preparing feed')).toBeInTheDocument();
268+
expect(
269+
screen.getByText('Creating the feed and loading its preview before showing the result.')
270+
).toBeInTheDocument();
271+
});
272+
253273
it('clears stored token from instance info', () => {
254274
mockUseAccessToken.mockReturnValue({
255275
token: 'saved-token',

frontend/src/__tests__/ResultDisplay.test.tsx

Lines changed: 38 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,39 +6,41 @@ import { ResultDisplay } from '../components/ResultDisplay';
66
describe('ResultDisplay', () => {
77
const mockOnCreateAnother = vi.fn();
88
const mockResult = {
9-
id: 'test-id',
10-
name: 'Test Feed',
11-
url: 'https://example.com',
12-
strategy: 'faraday',
13-
feed_token: 'test-feed-token',
14-
public_url: 'https://example.com/feed.xml',
15-
json_public_url: 'https://example.com/feed.json',
9+
feed: {
10+
id: 'test-id',
11+
name: 'Test Feed',
12+
url: 'https://example.com',
13+
strategy: 'faraday',
14+
feed_token: 'test-feed-token',
15+
public_url: 'https://example.com/feed.xml',
16+
json_public_url: 'https://example.com/feed.json',
17+
},
18+
preview: {
19+
items: [
20+
{
21+
title: 'Item One',
22+
excerpt: 'First preview item with markup.',
23+
url: 'https://example.com/item-one',
24+
publishedLabel: 'Jan 1, 2024',
25+
},
26+
{
27+
title: '56 points by canpan 1 hour ago | hide | 18 comments',
28+
excerpt: '',
29+
publishedLabel: 'Jan 2, 2024',
30+
},
31+
{
32+
title: 'Item Two',
33+
excerpt: '',
34+
url: 'https://example.com/item-two',
35+
publishedLabel: 'Jan 3, 2024',
36+
},
37+
],
38+
error: null,
39+
},
1640
};
1741

1842
beforeEach(() => {
1943
vi.clearAllMocks();
20-
vi.spyOn(window, 'fetch').mockResolvedValue({
21-
ok: true,
22-
json: async () => ({
23-
items: [
24-
{
25-
title: 'Item One',
26-
content_text: '<p>First preview item with <strong>markup</strong>.</p>',
27-
url: 'https://example.com/item-one',
28-
date_published: '2024-01-01T00:00:00Z',
29-
},
30-
{
31-
content_text: '56 points by canpan 1 hour ago | hide | 18&nbsp;comments',
32-
date_published: '2024-01-02T00:00:00Z',
33-
},
34-
{
35-
content_text: '2. Item Two ( example.com )',
36-
url: 'https://example.com/item-two',
37-
date_published: '2024-01-03T00:00:00Z',
38-
},
39-
],
40-
}),
41-
} as Response);
4244
});
4345

4446
it('renders the success state actions and richer preview cards', async () => {
@@ -60,18 +62,15 @@ describe('ResultDisplay', () => {
6062
expect(screen.getByText('Item Two')).toBeInTheDocument();
6163
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
6264
});
63-
expect(window.fetch).toHaveBeenCalledWith('https://example.com/feed.xml', {
64-
headers: { Accept: 'application/feed+json' },
65-
});
6665
});
6766

68-
it('surfaces preview fetch failures as a result-state message', async () => {
69-
vi.mocked(window.fetch).mockResolvedValueOnce({
70-
ok: false,
71-
json: async () => ({}),
72-
} as Response);
73-
74-
render(<ResultDisplay result={mockResult} onCreateAnother={mockOnCreateAnother} />);
67+
it('surfaces preview failures as a result-state message', async () => {
68+
render(
69+
<ResultDisplay
70+
result={{ ...mockResult, preview: { items: [], error: 'Preview unavailable right now.' } }}
71+
onCreateAnother={mockOnCreateAnother}
72+
/>
73+
);
7574

7675
await waitFor(() => {
7776
expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument();

frontend/src/__tests__/setup.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import '@testing-library/jest-dom';
22
import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest';
33
import { cleanup } from '@testing-library/preact';
4-
import { server } from './mocks/server';
4+
5+
let server: typeof import('./mocks/server').server;
56

67
// Mock window and document for tests
78
Object.defineProperty(window, 'matchMedia', {
@@ -49,10 +50,16 @@ const session = createStorageMock();
4950
Object.defineProperty(window, 'localStorage', {
5051
value: local.api,
5152
});
53+
Object.defineProperty(globalThis, 'localStorage', {
54+
value: local.api,
55+
});
5256

5357
Object.defineProperty(window, 'sessionStorage', {
5458
value: session.api,
5559
});
60+
Object.defineProperty(globalThis, 'sessionStorage', {
61+
value: session.api,
62+
});
5663

5764
beforeEach(() => {
5865
local.store.clear();
@@ -80,7 +87,10 @@ Object.assign(navigator, {
8087
Element.prototype.scrollIntoView = vi.fn();
8188

8289
// Wire up MSW in node environment
83-
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
90+
beforeAll(async () => {
91+
({ server } = await import('./mocks/server'));
92+
server.listen({ onUnhandledRequest: 'error' });
93+
});
8494
afterEach(() => {
8595
server.resetHandlers();
8696
cleanup();

0 commit comments

Comments
 (0)