Fossil SCM

In browsers which support Web Locks, do not allow the Edit or Reply buttons to function if an Edit resp. Reply is open for that same post in another tab. Instead, display an error to the user describing the problem. The Edit/Reply buttons will start working once the locking tab is closed. This lock is per draft, not per tab, so it is legal to edit different posts in different tabs or multiple posts in the same tab. In browsers without Web Locks support, most-recent-edit-wins applies.

stephan 2026-06-10 19:40 UTC forum-editor-2026
Commit e48fe702cac8249393ea96cbf9ba6809870286b73cd307e39fba18970de2f6aa
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -71,10 +71,15 @@
7171
opt.onsubmit[=function]: if set, this function is called
7272
immediately after the post has been successfully saved, and
7373
passed this object and a JSON-format response object from the
7474
save request. It is generally then up to the caller to close()
7575
this object and/or redirect to /forumpost/${arguments[1].uuid}.
76
+
77
+ opt.onclose[=function]: like opt.onsubmit, this function is
78
+ called when this.close() is called, and passed no arguments.
79
+ onclose() is called before the widget is removed from the dom
80
+ and _does not_ fire if it is not in the DOM.
7681
7782
opt.hiddenFields: an optional list of input elements to
7883
incorporate into the form for requests which request the
7984
preview or save the post.
8085
@@ -516,18 +521,19 @@
516521
D.append(
517522
D.li(list),
518523
"WARNING: draft edits are keyed on the ID of the message they ",
519524
"are editing or responding to. Attempting to edit or reply to ",
520525
"the same post from multiple tabs will cause the most-recently-edited ",
521
- "one to overwrite the draft slot for that post."
526
+ "one to overwrite the draft slot for that post. In browsers which support ",
527
+ "Web Locks, a second attempt to edit or reply to a post will be blocked ",
528
+ "and an error will be shown explaning the problme."
522529
);
523530
if( this.#e.status ){
524531
D.append(
525532
D.li(list),
526
- "Tip: to change just the status, use the widget which appears in ",
527
- "the post, not the editor. That will save only a single tag instead of ",
528
- "a new edit of the post."
533
+ "Tip: changing just the status in the editor will change only that, ",
534
+ "not a whole new (but unedited) copy of the post."
529535
);
530536
}
531537
eh.append(list);
532538
}
533539
@@ -1064,13 +1070,68 @@
10641070
delete eButton.dataset.originalLabel;
10651071
}
10661072
D.enable(eToDisable);
10671073
};
10681074
1069
- const replyClicked = (form, ePost, eBtnReply, eToDisable)=>{
1070
- const fpid = setupEditReplyElement(ePost, eBtnReply, eToDisable);
1075
+ /**
1076
+ Reports an error regarding the forum post element
1077
+ ePost, appending each entry in msg to a wrapper
1078
+ element with the class
1079
+ */
1080
+ const reportFPEError = (ePost,...msg)=>{
1081
+ const e = D.addClass(D.p(), 'error');
1082
+ e.append(
1083
+ ...msg,
1084
+ D.br(),
1085
+ D.button("Clear error", ()=>e.remove())
1086
+ );
1087
+ ePost.append(e);
1088
+ };
1089
+
1090
+ const replyClicked = async (form, ePost, eBtnReply, eToDisable)=>{
1091
+ const fpid = ePost.dataset.fpid;
10711092
const fEditHead = ePost.dataset.fedithead;
1093
+ const draftKey = makeDraftKey(
1094
+ 'draft-reply', fEditHead
1095
+ /* The problem with firt as a key is that firt is not
1096
+ necessarily the root edit of that post, which is
1097
+ what we really want as a draft key so that the
1098
+ draft does not disapper if firt is later edited
1099
+ (giving us a new firt value here). */
1100
+ || fpid
1101
+ );
1102
+ const lockName = 'fossil-'+draftKey;
1103
+ let releaseLock;
1104
+
1105
+ if( window.navigator.locks ){
1106
+ releaseLock = await new Promise((resolve)=>{
1107
+ window.navigator.locks.request(
1108
+ lockName, { ifAvailable: true }, async (lock) => {
1109
+ if( !lock ){
1110
+ /*lock contention*/
1111
+ resolve(null);
1112
+ return;
1113
+ }
1114
+ let release;
1115
+ const lockReleased = new Promise(res=>release=res);
1116
+ resolve(release);
1117
+ await lockReleased/*hold the lock open*/;
1118
+ });
1119
+ });
1120
+ if( !releaseLock ){
1121
+ reportFPEError(
1122
+ ePost,
1123
+ "This post is actively being replied to ",
1124
+ "in another tab. To avoid losing edits, ",
1125
+ "it cannot be opened here until the locking ",
1126
+ "tab is closed."
1127
+ );
1128
+ return;
1129
+ }
1130
+ }
1131
+
1132
+ setupEditReplyElement(ePost, eBtnReply, eToDisable);
10721133
eBtnReply.innerText = "Replying...";
10731134
const ondone = (fpe, response)=>{
10741135
/* onsubmit() and ondiscard() callback */
10751136
restoreEditReplyElement(ePost, eBtnReply, eToDisable);
10761137
//console.debug("ondiscard/onsubmit", fpe, artifact);
@@ -1086,27 +1147,55 @@
10861147
/* Do not inherit the fpid field, else this will become
10871148
an edit to that post rather than a response. */
10881149
),
10891150
ondiscard: ondone,
10901151
onsubmit: ondone,
1152
+ onclose: ()=>{
1153
+ if( releaseLock ){
1154
+ releaseLock();
1155
+ releaseLock = null;
1156
+ }
1157
+ },
10911158
inReplyTo: fpid,
1092
- draftKey: makeDraftKey('draft-reply', fEditHead
1093
- /* The problem with firt as a key is that firt is not
1094
- necessarily the root edit of that post, which is
1095
- what we really want as a draft key so that the
1096
- draft does not disapper if firt is later edited
1097
- (giving us a new firt value here). */
1098
- || fpid
1099
- )
1159
+ draftKey
11001160
});
11011161
initFPEWidget(ePost, fpe);
11021162
}/*replyClicked()*/;
11031163
1104
- const editClicked = (form, ePost, eBtnEdit, eToDisable)=>{
1105
- const fpid = setupEditReplyElement(ePost, eBtnEdit, eToDisable);
1164
+ const editClicked = async (form, ePost, eBtnEdit, eToDisable)=>{
1165
+ const fpid = ePost.dataset.fpid;
11061166
const firt = ePost.dataset.firt;
11071167
const fEditHead = ePost.dataset.fedithead;
1168
+ const draftKey = makeDraftKey('draft-forumedit', fEditHead || fpid);
1169
+ const lockName = 'fossil-'+draftKey;
1170
+ let releaseLock;
1171
+ if( navigator.locks ){
1172
+ releaseLock = await new Promise((resolve) => {
1173
+ navigator.locks.request(lockName, {ifAvailable: true}, async (lock)=>{
1174
+ if( !lock ){
1175
+ resolve(null);
1176
+ return;
1177
+ }
1178
+ let release;
1179
+ const lockReleased = new Promise(res=>release=res);
1180
+ resolve(release);
1181
+ await lockReleased;
1182
+ });
1183
+ });
1184
+
1185
+ if( !releaseLock ){
1186
+ reportFPEError(
1187
+ ePost,
1188
+ "This post is actively being edited ",
1189
+ "in another tab. To avoid losing edits, ",
1190
+ "it cannot be opened here until the locking ",
1191
+ "tab is closed."
1192
+ );
1193
+ return;
1194
+ }
1195
+ }
1196
+ setupEditReplyElement(ePost, eBtnEdit, eToDisable);
11081197
eBtnEdit.innerText = "Editing...";
11091198
fetchPost(fpid)
11101199
.then(artifact=>{
11111200
const ondone = (fpe, response)=>{
11121201
/* onsubmit() and ondiscard() callback */
@@ -1131,16 +1220,31 @@
11311220
11321221
const fpe = new F.ForumPostEditor({
11331222
hiddenFields: form.querySelectorAll('input[type=hidden]'),
11341223
ondiscard: ondone,
11351224
onsubmit: ondone,
1136
- draftKey: makeDraftKey('draft-forumedit', fEditHead || fpid),
1225
+ onclose: ()=>{
1226
+ if(releaseLock){
1227
+ releaseLock();
1228
+ releaseLock = null;
1229
+ }
1230
+ },
1231
+ draftKey,
11371232
edit: artifact,
11381233
status: eStatusSelect?.value,
11391234
inReplyTo: firt
11401235
});
11411236
initFPEWidget(ePost, fpe);
1237
+ })
1238
+ .catch(err=>{
1239
+ if( releaseLock ){
1240
+ releaseLock();
1241
+ releaseLock = null;
1242
+ }
1243
+ restoreEditReplyElement(ePost, eBtnEdit, eToDisable);
1244
+ console.error("Error fetching post:", err);
1245
+ reportFPEError(ePost, "Error fetching post: ", err.message);
11421246
});
11431247
}/*editClicked()*/;
11441248
11451249
document.body.querySelectorAll(
11461250
'.forumpost-single-controls > form'
11471251
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -71,10 +71,15 @@
71 opt.onsubmit[=function]: if set, this function is called
72 immediately after the post has been successfully saved, and
73 passed this object and a JSON-format response object from the
74 save request. It is generally then up to the caller to close()
75 this object and/or redirect to /forumpost/${arguments[1].uuid}.
 
 
 
 
 
76
77 opt.hiddenFields: an optional list of input elements to
78 incorporate into the form for requests which request the
79 preview or save the post.
80
@@ -516,18 +521,19 @@
516 D.append(
517 D.li(list),
518 "WARNING: draft edits are keyed on the ID of the message they ",
519 "are editing or responding to. Attempting to edit or reply to ",
520 "the same post from multiple tabs will cause the most-recently-edited ",
521 "one to overwrite the draft slot for that post."
 
 
522 );
523 if( this.#e.status ){
524 D.append(
525 D.li(list),
526 "Tip: to change just the status, use the widget which appears in ",
527 "the post, not the editor. That will save only a single tag instead of ",
528 "a new edit of the post."
529 );
530 }
531 eh.append(list);
532 }
533
@@ -1064,13 +1070,68 @@
1064 delete eButton.dataset.originalLabel;
1065 }
1066 D.enable(eToDisable);
1067 };
1068
1069 const replyClicked = (form, ePost, eBtnReply, eToDisable)=>{
1070 const fpid = setupEditReplyElement(ePost, eBtnReply, eToDisable);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1071 const fEditHead = ePost.dataset.fedithead;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1072 eBtnReply.innerText = "Replying...";
1073 const ondone = (fpe, response)=>{
1074 /* onsubmit() and ondiscard() callback */
1075 restoreEditReplyElement(ePost, eBtnReply, eToDisable);
1076 //console.debug("ondiscard/onsubmit", fpe, artifact);
@@ -1086,27 +1147,55 @@
1086 /* Do not inherit the fpid field, else this will become
1087 an edit to that post rather than a response. */
1088 ),
1089 ondiscard: ondone,
1090 onsubmit: ondone,
 
 
 
 
 
 
1091 inReplyTo: fpid,
1092 draftKey: makeDraftKey('draft-reply', fEditHead
1093 /* The problem with firt as a key is that firt is not
1094 necessarily the root edit of that post, which is
1095 what we really want as a draft key so that the
1096 draft does not disapper if firt is later edited
1097 (giving us a new firt value here). */
1098 || fpid
1099 )
1100 });
1101 initFPEWidget(ePost, fpe);
1102 }/*replyClicked()*/;
1103
1104 const editClicked = (form, ePost, eBtnEdit, eToDisable)=>{
1105 const fpid = setupEditReplyElement(ePost, eBtnEdit, eToDisable);
1106 const firt = ePost.dataset.firt;
1107 const fEditHead = ePost.dataset.fedithead;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1108 eBtnEdit.innerText = "Editing...";
1109 fetchPost(fpid)
1110 .then(artifact=>{
1111 const ondone = (fpe, response)=>{
1112 /* onsubmit() and ondiscard() callback */
@@ -1131,16 +1220,31 @@
1131
1132 const fpe = new F.ForumPostEditor({
1133 hiddenFields: form.querySelectorAll('input[type=hidden]'),
1134 ondiscard: ondone,
1135 onsubmit: ondone,
1136 draftKey: makeDraftKey('draft-forumedit', fEditHead || fpid),
 
 
 
 
 
 
1137 edit: artifact,
1138 status: eStatusSelect?.value,
1139 inReplyTo: firt
1140 });
1141 initFPEWidget(ePost, fpe);
 
 
 
 
 
 
 
 
 
1142 });
1143 }/*editClicked()*/;
1144
1145 document.body.querySelectorAll(
1146 '.forumpost-single-controls > form'
1147
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -71,10 +71,15 @@
71 opt.onsubmit[=function]: if set, this function is called
72 immediately after the post has been successfully saved, and
73 passed this object and a JSON-format response object from the
74 save request. It is generally then up to the caller to close()
75 this object and/or redirect to /forumpost/${arguments[1].uuid}.
76
77 opt.onclose[=function]: like opt.onsubmit, this function is
78 called when this.close() is called, and passed no arguments.
79 onclose() is called before the widget is removed from the dom
80 and _does not_ fire if it is not in the DOM.
81
82 opt.hiddenFields: an optional list of input elements to
83 incorporate into the form for requests which request the
84 preview or save the post.
85
@@ -516,18 +521,19 @@
521 D.append(
522 D.li(list),
523 "WARNING: draft edits are keyed on the ID of the message they ",
524 "are editing or responding to. Attempting to edit or reply to ",
525 "the same post from multiple tabs will cause the most-recently-edited ",
526 "one to overwrite the draft slot for that post. In browsers which support ",
527 "Web Locks, a second attempt to edit or reply to a post will be blocked ",
528 "and an error will be shown explaning the problme."
529 );
530 if( this.#e.status ){
531 D.append(
532 D.li(list),
533 "Tip: changing just the status in the editor will change only that, ",
534 "not a whole new (but unedited) copy of the post."
 
535 );
536 }
537 eh.append(list);
538 }
539
@@ -1064,13 +1070,68 @@
1070 delete eButton.dataset.originalLabel;
1071 }
1072 D.enable(eToDisable);
1073 };
1074
1075 /**
1076 Reports an error regarding the forum post element
1077 ePost, appending each entry in msg to a wrapper
1078 element with the class
1079 */
1080 const reportFPEError = (ePost,...msg)=>{
1081 const e = D.addClass(D.p(), 'error');
1082 e.append(
1083 ...msg,
1084 D.br(),
1085 D.button("Clear error", ()=>e.remove())
1086 );
1087 ePost.append(e);
1088 };
1089
1090 const replyClicked = async (form, ePost, eBtnReply, eToDisable)=>{
1091 const fpid = ePost.dataset.fpid;
1092 const fEditHead = ePost.dataset.fedithead;
1093 const draftKey = makeDraftKey(
1094 'draft-reply', fEditHead
1095 /* The problem with firt as a key is that firt is not
1096 necessarily the root edit of that post, which is
1097 what we really want as a draft key so that the
1098 draft does not disapper if firt is later edited
1099 (giving us a new firt value here). */
1100 || fpid
1101 );
1102 const lockName = 'fossil-'+draftKey;
1103 let releaseLock;
1104
1105 if( window.navigator.locks ){
1106 releaseLock = await new Promise((resolve)=>{
1107 window.navigator.locks.request(
1108 lockName, { ifAvailable: true }, async (lock) => {
1109 if( !lock ){
1110 /*lock contention*/
1111 resolve(null);
1112 return;
1113 }
1114 let release;
1115 const lockReleased = new Promise(res=>release=res);
1116 resolve(release);
1117 await lockReleased/*hold the lock open*/;
1118 });
1119 });
1120 if( !releaseLock ){
1121 reportFPEError(
1122 ePost,
1123 "This post is actively being replied to ",
1124 "in another tab. To avoid losing edits, ",
1125 "it cannot be opened here until the locking ",
1126 "tab is closed."
1127 );
1128 return;
1129 }
1130 }
1131
1132 setupEditReplyElement(ePost, eBtnReply, eToDisable);
1133 eBtnReply.innerText = "Replying...";
1134 const ondone = (fpe, response)=>{
1135 /* onsubmit() and ondiscard() callback */
1136 restoreEditReplyElement(ePost, eBtnReply, eToDisable);
1137 //console.debug("ondiscard/onsubmit", fpe, artifact);
@@ -1086,27 +1147,55 @@
1147 /* Do not inherit the fpid field, else this will become
1148 an edit to that post rather than a response. */
1149 ),
1150 ondiscard: ondone,
1151 onsubmit: ondone,
1152 onclose: ()=>{
1153 if( releaseLock ){
1154 releaseLock();
1155 releaseLock = null;
1156 }
1157 },
1158 inReplyTo: fpid,
1159 draftKey
 
 
 
 
 
 
 
1160 });
1161 initFPEWidget(ePost, fpe);
1162 }/*replyClicked()*/;
1163
1164 const editClicked = async (form, ePost, eBtnEdit, eToDisable)=>{
1165 const fpid = ePost.dataset.fpid;
1166 const firt = ePost.dataset.firt;
1167 const fEditHead = ePost.dataset.fedithead;
1168 const draftKey = makeDraftKey('draft-forumedit', fEditHead || fpid);
1169 const lockName = 'fossil-'+draftKey;
1170 let releaseLock;
1171 if( navigator.locks ){
1172 releaseLock = await new Promise((resolve) => {
1173 navigator.locks.request(lockName, {ifAvailable: true}, async (lock)=>{
1174 if( !lock ){
1175 resolve(null);
1176 return;
1177 }
1178 let release;
1179 const lockReleased = new Promise(res=>release=res);
1180 resolve(release);
1181 await lockReleased;
1182 });
1183 });
1184
1185 if( !releaseLock ){
1186 reportFPEError(
1187 ePost,
1188 "This post is actively being edited ",
1189 "in another tab. To avoid losing edits, ",
1190 "it cannot be opened here until the locking ",
1191 "tab is closed."
1192 );
1193 return;
1194 }
1195 }
1196 setupEditReplyElement(ePost, eBtnEdit, eToDisable);
1197 eBtnEdit.innerText = "Editing...";
1198 fetchPost(fpid)
1199 .then(artifact=>{
1200 const ondone = (fpe, response)=>{
1201 /* onsubmit() and ondiscard() callback */
@@ -1131,16 +1220,31 @@
1220
1221 const fpe = new F.ForumPostEditor({
1222 hiddenFields: form.querySelectorAll('input[type=hidden]'),
1223 ondiscard: ondone,
1224 onsubmit: ondone,
1225 onclose: ()=>{
1226 if(releaseLock){
1227 releaseLock();
1228 releaseLock = null;
1229 }
1230 },
1231 draftKey,
1232 edit: artifact,
1233 status: eStatusSelect?.value,
1234 inReplyTo: firt
1235 });
1236 initFPEWidget(ePost, fpe);
1237 })
1238 .catch(err=>{
1239 if( releaseLock ){
1240 releaseLock();
1241 releaseLock = null;
1242 }
1243 restoreEditReplyElement(ePost, eBtnEdit, eToDisable);
1244 console.error("Error fetching post:", err);
1245 reportFPEError(ePost, "Error fetching post: ", err.message);
1246 });
1247 }/*editClicked()*/;
1248
1249 document.body.querySelectorAll(
1250 '.forumpost-single-controls > form'
1251
+12 -5
--- www/forum.wiki
+++ www/forum.wiki
@@ -534,10 +534,22 @@
534534
If neither <code>localStorage</code> nor <code>sessionStorage</code>
535535
are available, it uses a transient storage object which is recreated
536536
on each page-load, so it cannot store drafts outside of a single
537537
thread at a time.
538538
539
+Beware: Each draft is keyed to the post it is editing or replied to,
540
+which means that editing or replying to the same post from different
541
+tabs will cause collisions. The editor to most recently lose the focus
542
+will save the draft, overwriting the other.
543
+
544
+In browsers which support Web Locks, attempts to edit resp. reply to a
545
+post which is already being edited resp. replied to in another tab
546
+will show an error for the second and subsequent tabs. The first tab
547
+to use the edit/reply buttons will hold a separate Web Lock for each
548
+and forbid edit/reply access to the post in other tabs until the
549
+locking tab is closed.
550
+
539551
540552
<h2 name="padding">Curious Extra Page Padding</h2>
541553
542554
Users may notice that the forum pages get a bunch of padding added to
543555
the bottom of the page when an editor widget is opened. This is not a
@@ -545,10 +557,5 @@
545557
rendering a preview wildly resizes the widget. The effect is
546558
especially helpful when working on posts while the bottom footer of
547559
the page is in the viewport, as the footer is a hard boundary against
548560
scrolling. With the extra padding, the editor widget's top edge shifts
549561
around less when the preview is shown and hidden.
550
-
551
-Beware: Each draft is keyed to the post it is editing or replied to,
552
-which means that editing or replying to the same post from different
553
-tabs will cause collisions. The editor to most recently lose the focus
554
-will save the draft, overwriting the other.
555562
--- www/forum.wiki
+++ www/forum.wiki
@@ -534,10 +534,22 @@
534 If neither <code>localStorage</code> nor <code>sessionStorage</code>
535 are available, it uses a transient storage object which is recreated
536 on each page-load, so it cannot store drafts outside of a single
537 thread at a time.
538
 
 
 
 
 
 
 
 
 
 
 
 
539
540 <h2 name="padding">Curious Extra Page Padding</h2>
541
542 Users may notice that the forum pages get a bunch of padding added to
543 the bottom of the page when an editor widget is opened. This is not a
@@ -545,10 +557,5 @@
545 rendering a preview wildly resizes the widget. The effect is
546 especially helpful when working on posts while the bottom footer of
547 the page is in the viewport, as the footer is a hard boundary against
548 scrolling. With the extra padding, the editor widget's top edge shifts
549 around less when the preview is shown and hidden.
550
551 Beware: Each draft is keyed to the post it is editing or replied to,
552 which means that editing or replying to the same post from different
553 tabs will cause collisions. The editor to most recently lose the focus
554 will save the draft, overwriting the other.
555
--- www/forum.wiki
+++ www/forum.wiki
@@ -534,10 +534,22 @@
534 If neither <code>localStorage</code> nor <code>sessionStorage</code>
535 are available, it uses a transient storage object which is recreated
536 on each page-load, so it cannot store drafts outside of a single
537 thread at a time.
538
539 Beware: Each draft is keyed to the post it is editing or replied to,
540 which means that editing or replying to the same post from different
541 tabs will cause collisions. The editor to most recently lose the focus
542 will save the draft, overwriting the other.
543
544 In browsers which support Web Locks, attempts to edit resp. reply to a
545 post which is already being edited resp. replied to in another tab
546 will show an error for the second and subsequent tabs. The first tab
547 to use the edit/reply buttons will hold a separate Web Lock for each
548 and forbid edit/reply access to the post in other tabs until the
549 locking tab is closed.
550
551
552 <h2 name="padding">Curious Extra Page Padding</h2>
553
554 Users may notice that the forum pages get a bunch of padding added to
555 the bottom of the page when an editor widget is opened. This is not a
@@ -545,10 +557,5 @@
557 rendering a preview wildly resizes the widget. The effect is
558 especially helpful when working on posts while the bottom footer of
559 the page is in the viewport, as the footer is a hard boundary against
560 scrolling. With the extra padding, the editor widget's top edge shifts
561 around less when the preview is shown and hidden.
 
 
 
 
 
562

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button