Offer to try the open again wherever it may work later - #299
Conversation
The viewer offered "Try again" only for a row that waits, by its code, and the embed page offered nothing at all. Since #298 the server says which answers the same request may get past later - a row still waiting, a file locked for a moment, this instance's Etherpad not reachable - with retryable: true. fetchJsonWithTimeout() carries that flag onto the error as it carries the code, the viewer offers its second try on it, and the embed page's error panel gets the same button, labelled with the "Try again" it already had for content. The button runs the whole open again; a success clears the panel, and a second failure offers one more try, not two. Checked in the stack: with Etherpad disconnected the embed page shows "Etherpad cannot be reached right now. Try again later." and the button; with Etherpad back, the button opens the pad. The bundles are built with Node 24 from a tree whose build of main matches the committed js/ byte for byte.
… focus after one The client's own timeout and a failed network carried no retryable, so the two failures that are worth another try by nature were the ones without a button - a slow Etherpad ran the open into its ten seconds and left a dead end. fetchJsonWithTimeout() marks both retryable now; a caller's own abort stays unmarked. The embed page's button and the recovery's restart share restartOpen(), which hides every panel and shows the loading state before it runs the open again. After a second try that fails, the new button - or the message, when there is none - takes the focus the clicked button took with it, so a keyboard or screen reader keeps its place; not on the first load, where an embed that takes the focus scrolls its host page. architecture.md says the clients act on retryable.
The embed page's error panel starts from hideAllPanels() instead of hiding the loading state and the frame by hand, so the recovery card goes too. Its three kinds of button - the content's retry, the error panel's, the recovery card's - come from one buildButton(). The client comments no longer list which answers are retryable; they point to docs/api-reference.md, where the server's cases are. The rule for "Try again" is stated once, under "Errors of the API" in architecture.md, with the focus after a second try; the other places refer to it. Two pairs of tests that built the same scene are one each.
… answer fetchJsonWithTimeout() marked its own timeout and a failed network retryable for every caller, though only a caller knows whether its request writes. It now says only that nothing came back from this app, as unanswered: its timeout, a failed network, also while the body streams in, and a 502, 503 or 504 whose body is not this app's JSON, from a proxy or Nextcloud in maintenance. Those two answered with no button before. retryable is the server's word alone again. isRetryableOpenError() in pad-open-flow.js decides for both clients: the server's retryable, pad_file_changed, which an open meets only while initialising and after the server undid its part, and an open that got no answer. An initialise that got no answer gets no button, since its pad may be set up by now and another open would start a second one. The embed page's initialise and recovery no longer time out after ten seconds, as the viewer's never did. Both clients say "no answer" in a translated sentence of their own instead of the browser's English. The embed page hands the focus on after every click whose button went away, a recovery too, and to the recovery card when a second try ends there, without scrolling the host page. Its error panel is role="alert". An open that answers late cannot undo a newer one. The button classes are one constant, showError() takes a boolean, and the read-only view starts from hideAllPanels().
After a click on "Try again" or "Create new pad from this file" that ends in the error card again, the button the focus was on went away with it, and the focus fell to the page. The viewer now hands it to the card's first action, or to its message, on the next tick, as the embed page does; after a missing binding once the card's actions are drawn, and not on the first load. The card carries a ref for it. Checked in the stack with Etherpad disconnected: after a failed second try the focus is on the new button.
|
ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing |
PR Summary by QodoOffer retryable pad opens in viewer and embed
AI Description
Diagram
High-Level Assessment
Files changed (39)
|
Code Review by Qodo
1.
|
A 502, 503 or 504 counted as no answer only when its body was not JSON, so a proxy or gateway that answers in JSON of its own left the open without "Try again". This app answers every error with a message, and every 503 of its own has one, so a gateway status without a string message is no answer from it now, JSON or not. The table test holds the answer's status too, since a TypeError from reading a null body would have passed for no answer by accident. (Qodo on #299)
…s back after a failed recovery A 502, 503 or 504 counted as this app's answer when its JSON carried a message, which is exactly the shape of the usual JSON gateways. This app never answers 502 or 504, and every 503 of its own carries retryable; without it, any of the three is a gateway answering in its place now. The mapper tests hold that every 503 is retryable. An initialise that got no answer gets "Try again" again: the second try opens first and finds the pad if the first one set it up, and one still running is safe to meet, since the server compares the file before it writes, a file has one binding row and the pad that lost a race is rolled back. A recovery that got no answer is not offered again but opens the file, which shows the pad if it went through and the card if not. After a recovery that failed, the focus goes back to the card, as after a second try. The focus rule is one handFocusTo() in src/lib for both clients. The alert role sits on the message of each error card, the recovery card's and the viewer's too, not on a panel with buttons in it. The embed page shows its loading state in one place, and both clients name their error message and the missing-binding check once. The client tests take the server's answers from one file, the viewer's focus tests model Vue's order of drawing and focusing, and a spy no longer outlives a failing test.
…ery open from the loading state Two initialisations at once - the first unanswered but still running, the second from "Try again" - leave the loser with BindingNotCreatedException. Its sentence says to try again, but it carried no retryable, so neither client offered the button, though the next open finds the winner's pad. ApiErrorCode::retryable() takes it now. The create endpoints answer it with their own sentence and no flag, as before, and a test holds that. The embed page's run() starts from showLoading(), so showIframe() and showPadContentView() no longer hide the other panels themselves, and restartOpen() is gone: the button and the recovery call run(true). A test holds the generation guard after a success too: an open that succeeds late does not undo a newer failure.
|
Code review by qodo was updated up to the latest commit 791c337 |
The viewer and the embed page offer "Try again" wherever the same open may work later. Until now the embed page showed its errors without any action.
What changes
The viewer offered its second try only for a row that waits, by the code
waiting_binding. The embed page offered nothing at all, not even for that. Since #298 the server says which answers the same request may get past later, withretryable: true:409);503);503).This change adds a fourth: a file's row that another request made first (
BindingNotCreatedException,400). Two initialisations at once leave the loser with it; its sentence already said to try again, and the next open finds the winner's pad. The create endpoints answer it with their own sentence and neither field, as before.What the helper says.
fetchJsonWithTimeout()carriesretryableonto the error, as it carriescode; onlytruecounts. When nothing came back from this app, it marks the errorunanswered:502,503or504withoutretryable. This app never answers502or504, and every503of its own carriesretryable, so without it a proxy or Nextcloud in maintenance answered in its place, with a page or with JSON of its own. The mapper tests hold that every503is retryable, andapi-reference.mdsays so.That is only a fact, not a verdict: whether another try is safe depends on the request. A caller's own abort stays unmarked.
Who decides.
isRetryableOpenError()inpad-open-flow.jsdecides for both clients. It offers another try on:retryable;pad_file_changed, which an open meets only while initialising the file, after the server undid its part;The second try opens first, so it finds a pad the first try set up. One still being set up is safe to meet: the server compares the file before it writes, a file has one binding row, and a pad that lost either race is rolled back. Losing the race for the row is retryable itself (above), so that ends with a button too.
Both clients show "no answer" as their own translated sentence, "Nextcloud did not answer. Check your connection and try again.", rather than the browser's English ("Failed to fetch", "Load failed").
Writes wait. The embed page's initialise and recovery no longer time out after ten seconds, as the viewer's never did. Cut short, a write goes on with nobody left to read the outcome. A recovery that got no answer is not offered again, since a second one would meet the first: both clients open the file instead, which shows the pad if the recovery went through and the recovery card if not.
The embed page.
showLoading()at the start ofrun().run()has a generation guard, as the content view and the viewer do.Focus and screen readers.
handFocusTo()insrc/lib/hand-focus.js.role="alert": the embed page's error panel, its recovery card, and the viewer's card. An error is read out when it appears, the first one too, without the buttons around it.Internals. The embed page's buttons come from one
buildButton()with one class constant,showError()takes a boolean, and the panels no longer hide each other by hand: every open starts fromshowLoading(). Both clients name their error message (messageOf()) and the missing-binding check (isMissingBindingError()) once.The rule is stated once, under "Errors of the API" in
architecture.md;api-reference.mdrefers to it. PHP changes:ApiErrorCode::retryable()takes the lost race for a row, the template's place for the button, the alert roles, the new sentence passed throughEmbedControllerin de, es, fr and it, and the tests for the503s.Checked in the stack
NC 34.0.3, Etherpad 2, driven with Playwright against the stack, with screenshots.
With Etherpad disconnected, for the embed page of a protected pad:
With the open request failed on the network, or answered by a
502gateway page, a503HTML page as in maintenance, or a502with a gateway's own JSONmessage:For a copy of a
.padfile, on its recovery card:In the Files viewer, with Etherpad disconnected:
Bundles
The bundles are built with Node 24 in a copy of the tree with a real
node_modules, since a symlinked one writes its path into the source maps. The same environment buildsmainbyte for byte like the committedjs/, and building this branch again gives the committedjs/.What changes in them:
fetch-helpersandpad-open-flowget new hashes.Tests
fetch-helpers:retryableis carried only when the server saystrue.unanswered, notretryable, also while the body streams in.502,503or504withoutretryableisunanswered, as a page, as JSON of its own and as JSON with amessage. This app's own503and a500page are not.handFocusTo(): the first action, else the message with atabindex, withpreventScrollonly when asked, and nothing without a target.503carriesretryable, and so does a lost race for a file's row; a create's own wording for it carries neither code nor flag.messagecheck, or taking this app's503or any status for no answer;handFocusTo()withouttabindex, scrolling regardless, or putting the message before the action;Left as it is
POSTstill times out after ten seconds, though it writes. It reports the timeout to the host asnetwork, and changing what a host is told belongs in its own change.TypeErrorfromfetch()counts as no answer. That also covers a URL or header valuefetch()rejects, and a redirect to another origin, from a login proxy whose session ran out. Neither can be told from a failed network: the URLs come from the server's URL generator, the header is the server's token, and behind such a proxy the rest of Nextcloud fails the same way until a reload.showCard()would replace two template blocks, two sets of CSS and four show functions. That predates this change and belongs in one of its own.Numbers