loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Change form actions to fetch for submit review box (#25219)

Co-author: @wxiaoguang

Close #25096

The way to fix it in this PR is to change form submit to fetch using
formData, and add flags to avoid post repeatedly.
Should be able to apply to more forms that have the same issue after
this PR.

In the demo below, 'approve' is clicked several times, and then
'comment' is clicked several time after 'request changes' clicked.

After:


https://github.com/go-gitea/gitea/assets/17645053/beabeb1d-fe66-4b76-b048-4f022b4e83a0


Update: screenshots from /devtest

>
![image](https://user-images.githubusercontent.com/2114189/245680011-ee4231e0-a53d-4c2a-a9c2-71ccd98005cc.png)
>
>
![image](https://user-images.githubusercontent.com/2114189/245680057-9215d348-63d8-406d-8828-17e171163aaa.png)
>
>
![image](https://user-images.githubusercontent.com/2114189/245680148-89d7b3d1-d7b6-442f-b69e-eadaee112482.png)

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>

authored by

HesterG
wxiaoguang
and committed by
GitHub
a43ea224 6348823e

+208 -36
+4
modules/context/base.go
··· 132 132 } 133 133 } 134 134 135 + func (b *Base) JSONRedirect(redirect string) { 136 + b.JSON(http.StatusOK, map[string]any{"redirect": redirect}) 137 + } 138 + 135 139 // RemoteAddr returns the client machine ip address 136 140 func (b *Base) RemoteAddr() string { 137 141 return b.Req.RemoteAddr
+10
routers/web/devtest/devtest.go
··· 32 32 ctx.HTML(http.StatusOK, "devtest/list") 33 33 } 34 34 35 + func FetchActionTest(ctx *context.Context) { 36 + _ = ctx.Req.ParseForm() 37 + ctx.Flash.Info(ctx.Req.Method + " " + ctx.Req.RequestURI + "<br>" + 38 + "Form: " + ctx.Req.Form.Encode() + "<br>" + 39 + "PostForm: " + ctx.Req.PostForm.Encode(), 40 + ) 41 + time.Sleep(2 * time.Second) 42 + ctx.JSONRedirect("") 43 + } 44 + 35 45 func Tmpl(ctx *context.Context) { 36 46 now := time.Now() 37 47 ctx.Data["TimeNow"] = now
+4 -5
routers/web/repo/pull_review.go
··· 193 193 } 194 194 if ctx.HasError() { 195 195 ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) 196 - ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) 196 + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) 197 197 return 198 198 } 199 199 ··· 214 214 } 215 215 216 216 ctx.Flash.Error(translated) 217 - ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) 217 + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) 218 218 return 219 219 } 220 220 } ··· 228 228 if err != nil { 229 229 if issues_model.IsContentEmptyErr(err) { 230 230 ctx.Flash.Error(ctx.Tr("repo.issues.review.content.empty")) 231 - ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) 231 + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) 232 232 } else { 233 233 ctx.ServerError("SubmitReview", err) 234 234 } 235 235 return 236 236 } 237 - 238 - ctx.Redirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, issue.Index, comm.HashTag())) 237 + ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d#%s", ctx.Repo.RepoLink, issue.Index, comm.HashTag())) 239 238 } 240 239 241 240 // DismissReview dismissing stale review by repo admin
+1
routers/web/web.go
··· 1411 1411 1412 1412 if !setting.IsProd { 1413 1413 m.Any("/devtest", devtest.List) 1414 + m.Any("/devtest/fetch-action-test", devtest.FetchActionTest) 1414 1415 m.Any("/devtest/{sub}", devtest.Tmpl) 1415 1416 } 1416 1417
+42
templates/devtest/fetch-action.tmpl
··· 1 + {{template "base/head" .}} 2 + <div class="page-content devtest ui container"> 3 + {{template "base/alert" .}} 4 + <div> 5 + <h1>link-action</h1> 6 + <div> 7 + Use "window.fetch" to send a request to backend, the request is defined in an "A" or "BUTTON" element. 8 + It might be renamed to "link-fetch-action" to match the "form-fetch-action". 9 + </div> 10 + <div> 11 + <button class="link-action" data-url="fetch-action-test?k=1">test</button> 12 + </div> 13 + </div> 14 + <div> 15 + <h1>form-fetch-action</h1> 16 + <div>Use "window.fetch" to send a form request to backend</div> 17 + <div> 18 + <form method="get" action="fetch-action-test?k=1" class="form-fetch-action"> 19 + <button name="btn">submit get</button> 20 + </form> 21 + <form method="post" action="fetch-action-test?k=1" class="form-fetch-action"> 22 + <div><textarea name="text" rows="3" class="js-quick-submit"></textarea></div> 23 + <div><label><input name="check" type="checkbox"> check</label></div> 24 + <div><button name="btn">submit post</button></div> 25 + </form> 26 + <form method="post" action="/no-such-uri" class="form-fetch-action"> 27 + <div class="gt-py-5">bad action url</div> 28 + <div><button name="btn">submit test</button></div> 29 + </form> 30 + </div> 31 + </div> 32 + </div> 33 + <style> 34 + .ui.message.flash-message { 35 + text-align: left; 36 + } 37 + .form-fetch-action { 38 + margin-bottom: 1em; 39 + border: 1px red dashed; /* show the border for demo purpose */ 40 + } 41 + </style> 42 + {{template "base/footer" .}}
+11
templates/devtest/gitea-ui.tmpl
··· 90 90 </div> 91 91 92 92 <div> 93 + <h1>Loading</h1> 94 + <div class="is-loading small-loading-icon gt-border-secondary gt-py-2"><span>loading ...</span></div> 95 + <div class="is-loading gt-border-secondary gt-py-4"> 96 + <p>loading ...</p> 97 + <p>loading ...</p> 98 + <p>loading ...</p> 99 + <p>loading ...</p> 100 + </div> 101 + </div> 102 + 103 + <div> 93 104 <h1>GiteaOriginUrl</h1> 94 105 <div><gitea-origin-url data-url="test/url"></gitea-origin-url></div> 95 106 <div><gitea-origin-url data-url="/test/url"></gitea-origin-url></div>
+10 -7
templates/devtest/list.tmpl
··· 1 - <style> 2 - @media (prefers-color-scheme: dark) { 3 - :root { 4 - color-scheme: dark; 5 - } 6 - } 7 - </style> 1 + {{template "base/head" .}} 2 + 8 3 <ul> 9 4 {{range .SubNames}} 10 5 <li><a href="{{AppSubUrl}}/devtest/{{.}}">{{.}}</a></li> 11 6 {{end}} 12 7 </ul> 8 + 9 + <style> 10 + ul { 11 + line-height: 2em; 12 + } 13 + </style> 14 + 15 + {{template "base/footer" .}}
+1 -1
templates/repo/diff/new_review.tmpl
··· 6 6 </button> 7 7 <div class="review-box-panel tippy-target"> 8 8 <div class="ui segment"> 9 - <form class="ui form" action="{{.Link}}/reviews/submit" method="post"> 9 + <form class="ui form form-fetch-action" action="{{.Link}}/reviews/submit" method="post"> 10 10 {{.CsrfTokenHtml}} 11 11 <input type="hidden" name="commit_id" value="{{.AfterCommitID}}"> 12 12 <div class="field gt-df gt-ac">
+12 -9
web_src/css/modules/animations.css
··· 4 4 } 5 5 6 6 .is-loading { 7 - background: transparent !important; 8 - color: transparent !important; 9 - border: transparent !important; 10 7 pointer-events: none !important; 11 8 position: relative !important; 12 9 overflow: hidden !important; 13 10 } 14 11 12 + .is-loading > * { 13 + opacity: 0.3; 14 + } 15 + 15 16 .is-loading::after { 16 17 content: ""; 17 18 position: absolute; 18 19 display: block; 19 - width: 4rem; 20 20 height: 4rem; 21 + max-height: 50%; 22 + aspect-ratio: 1 / 1; 21 23 left: 50%; 22 24 top: 50%; 23 25 transform: translate(-50%, -50%); ··· 28 30 border-radius: 100%; 29 31 } 30 32 33 + .is-loading.small-loading-icon::after { 34 + border-width: 2px; 35 + } 36 + 31 37 .markup pre.is-loading, 32 38 .editor-loading.is-loading, 33 39 .pdf-content.is-loading { 34 40 height: var(--height-loading); 35 41 } 36 42 43 + /* TODO: not needed, use "is-loading small-loading-icon" instead */ 37 44 .btn-octicon.is-loading::after { 38 45 border-width: 2px; 39 46 height: 1.25rem; 40 47 width: 1.25rem; 41 48 } 42 49 50 + /* TODO: not needed, use "is-loading small-loading-icon" instead */ 43 51 code.language-math.is-loading::after { 44 52 padding: 0; 45 53 border-width: 2px; 46 54 width: 1.25rem; 47 55 height: 1.25rem; 48 - } 49 - 50 - #oauth2-login-navigator.is-loading::after { 51 - width: 40px; 52 - height: 40px; 53 56 } 54 57 55 58 @keyframes fadein {
+6
web_src/css/modules/tippy.css
··· 29 29 color: var(--color-text); 30 30 } 31 31 32 + .tippy-box[data-theme="form-fetch-error"] { 33 + border-color: var(--color-error-border); 34 + background-color: var(--color-error-bg); 35 + color: var(--color-error-text); 36 + } 37 + 32 38 .tippy-content { 33 39 position: relative; 34 40 padding: 1rem;
+81 -3
web_src/js/features/common-global.js
··· 7 7 import {svg} from '../svg.js'; 8 8 import {hideElem, showElem, toggleElem} from '../utils/dom.js'; 9 9 import {htmlEscape} from 'escape-goat'; 10 + import {createTippy} from '../modules/tippy.js'; 10 11 11 12 const {appUrl, csrfToken, i18n} = window.config; 12 13 ··· 60 61 }); 61 62 } 62 63 64 + async function formFetchAction(e) { 65 + if (!e.target.classList.contains('form-fetch-action')) return; 66 + 67 + e.preventDefault(); 68 + const formEl = e.target; 69 + if (formEl.classList.contains('is-loading')) return; 70 + 71 + formEl.classList.add('is-loading'); 72 + if (formEl.clientHeight < 50) { 73 + formEl.classList.add('small-loading-icon'); 74 + } 75 + 76 + const formMethod = formEl.getAttribute('method') || 'get'; 77 + const formActionUrl = formEl.getAttribute('action'); 78 + const formData = new FormData(formEl); 79 + const [submitterName, submitterValue] = [e.submitter?.getAttribute('name'), e.submitter?.getAttribute('value')]; 80 + if (submitterName) { 81 + formData.append(submitterName, submitterValue || ''); 82 + } 83 + 84 + let reqUrl = formActionUrl; 85 + const reqOpt = {method: formMethod.toUpperCase(), headers: {'X-Csrf-Token': csrfToken}}; 86 + if (formMethod.toLowerCase() === 'get') { 87 + const params = new URLSearchParams(); 88 + for (const [key, value] of formData) { 89 + params.append(key, value.toString()); 90 + } 91 + const pos = reqUrl.indexOf('?'); 92 + if (pos !== -1) { 93 + reqUrl = reqUrl.slice(0, pos); 94 + } 95 + reqUrl += `?${params.toString()}`; 96 + } else { 97 + reqOpt.body = formData; 98 + } 99 + 100 + let errorTippy; 101 + const onError = (msg) => { 102 + formEl.classList.remove('is-loading', 'small-loading-icon'); 103 + if (errorTippy) errorTippy.destroy(); 104 + errorTippy = createTippy(formEl, { 105 + content: msg, 106 + interactive: true, 107 + showOnCreate: true, 108 + hideOnClick: true, 109 + role: 'alert', 110 + theme: 'form-fetch-error', 111 + trigger: 'manual', 112 + arrow: false, 113 + }); 114 + }; 115 + 116 + const doRequest = async () => { 117 + try { 118 + const resp = await fetch(reqUrl, reqOpt); 119 + if (resp.status === 200) { 120 + const {redirect} = await resp.json(); 121 + formEl.classList.remove('dirty'); // remove the areYouSure check before reloading 122 + if (redirect) { 123 + window.location.href = redirect; 124 + } else { 125 + window.location.reload(); 126 + } 127 + } else { 128 + onError(`server error: ${resp.status}`); 129 + } 130 + } catch (e) { 131 + onError(e.error); 132 + } 133 + }; 134 + 135 + // TODO: add "confirm" support like "link-action" in the future 136 + await doRequest(); 137 + } 138 + 63 139 export function initGlobalCommon() { 64 140 // Semantic UI modules. 65 141 const $uiDropdowns = $('.ui.dropdown'); ··· 114 190 if (btn.classList.contains('loading')) return e.preventDefault(); 115 191 btn.classList.add('loading'); 116 192 }); 193 + 194 + document.addEventListener('submit', formFetchAction); 117 195 } 118 196 119 197 export function initGlobalDropzone() { ··· 182 260 const $this = $(e.target); 183 261 const redirect = $this.attr('data-redirect'); 184 262 185 - const request = () => { 263 + const doRequest = () => { 186 264 $this.prop('disabled', true); 187 265 $.post($this.attr('data-url'), { 188 266 _csrf: csrfToken ··· 201 279 202 280 const modalConfirmHtml = htmlEscape($this.attr('data-modal-confirm') || ''); 203 281 if (!modalConfirmHtml) { 204 - request(); 282 + doRequest(); 205 283 return; 206 284 } 207 285 ··· 220 298 $modal.appendTo(document.body); 221 299 $modal.modal({ 222 300 onApprove() { 223 - request(); 301 + doRequest(); 224 302 }, 225 303 onHidden() { 226 304 $modal.remove();
+14 -7
web_src/js/features/comp/QuickSubmit.js
··· 1 1 import $ from 'jquery'; 2 2 3 3 export function handleGlobalEnterQuickSubmit(target) { 4 - const $target = $(target); 5 - const $form = $(target).closest('form'); 6 - if ($form.length) { 4 + const form = target.closest('form'); 5 + if (form) { 6 + if (!form.checkValidity()) { 7 + form.reportValidity(); 8 + return; 9 + } 10 + 11 + if (form.classList.contains('form-fetch-action')) { 12 + form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true})); 13 + return; 14 + } 15 + 7 16 // here use the event to trigger the submit event (instead of calling `submit()` method directly) 8 17 // otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog 9 - if ($form[0].checkValidity()) { 10 - $form.trigger('submit'); 11 - } 18 + $(form).trigger('submit'); 12 19 } else { 13 20 // if no form, then the editor is for an AJAX request, dispatch an event to the target, let the target's event handler to do the AJAX request. 14 21 // the 'ce-' prefix means this is a CustomEvent 15 - $target.trigger('ce-quick-submit'); 22 + target.dispatchEvent(new CustomEvent('ce-quick-submit', {bubbles: true})); 16 23 } 17 24 }
+1 -1
web_src/js/features/repo-code.js
··· 111 111 hideOnClick: true, 112 112 content: menu, 113 113 placement: 'right-start', 114 - interactive: 'true', 114 + interactive: true, 115 115 onShow: (tippy) => { 116 116 tippy.popper.addEventListener('click', () => { 117 117 tippy.hide();
+11 -3
web_src/js/modules/tippy.js
··· 3 3 const visibleInstances = new Set(); 4 4 5 5 export function createTippy(target, opts = {}) { 6 + const {role, content, onHide: optsOnHide, onDestroy: optsOnDestroy, onShow: optOnShow} = opts; 7 + delete opts.onHide; 8 + delete opts.onDestroy; 9 + delete opts.onShow; 10 + 6 11 const instance = tippy(target, { 7 12 appendTo: document.body, 8 13 animation: false, ··· 13 18 maxWidth: 500, // increase over default 350px 14 19 onHide: (instance) => { 15 20 visibleInstances.delete(instance); 21 + return optsOnHide?.(instance); 16 22 }, 17 23 onDestroy: (instance) => { 18 24 visibleInstances.delete(instance); 25 + return optsOnDestroy?.(instance); 19 26 }, 20 27 onShow: (instance) => { 21 28 // hide other tooltip instances so only one tooltip shows at a time ··· 25 32 } 26 33 } 27 34 visibleInstances.add(instance); 35 + return optOnShow?.(instance); 28 36 }, 29 37 arrow: `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`, 30 38 role: 'menu', // HTML role attribute, only tooltips should use "tooltip" 31 - theme: opts.role || 'menu', // CSS theme, we support either "tooltip" or "menu" 39 + theme: role || 'menu', // CSS theme, we support either "tooltip" or "menu" 32 40 ...opts, 33 41 }); 34 42 35 43 // for popups where content refers to a DOM element, we use the 'tippy-target' class 36 44 // to initially hide the content, now we can remove it as the content has been removed 37 45 // from the DOM by tippy 38 - if (opts.content instanceof Element) { 39 - opts.content.classList.remove('tippy-target'); 46 + if (content instanceof Element) { 47 + content.classList.remove('tippy-target'); 40 48 } 41 49 42 50 return instance;