Skip to content

Commit c7611eb

Browse files
committed
watch: fix live reload for browse/mostrecent pages
The browse page watcher (handleWatchBrowse) had no hash-based catch-up mechanism, so changes during the brief page reload window were lost. To fix this, make browse content deterministic (move relative timestamps to client-side JS) so that the content hash is stable across renders. Then use the same hash-based catch-up that regular page watching uses: re-generate the browse content, compare hashes, notify if different. Also close the EventSource before calling location.reload() to prevent the browser auto-reconnect from racing with the page reload: location.reload() is asynchronous — JS keeps running briefly after the call. During that window the browser tears down the SSE connection, and EventSource interprets that as a network error, triggering its built-in reconnect. The reconnected request races with (or even outlives) the page unload, potentially resulting in duplicate SSE connections. The existing unload/pagehide handlers cannot prevent this because they fire asynchronously and may not run before EventSource reconnects. Calling updates.close() synchronously inside onmessage (the same thing unwatch() already does) is the only reliable fix. Switch browse.js from async to defer: the script now queries <time datetime> elements to add relative timestamps client-side, so it must run after the DOM is fully parsed. With async, the script could execute before those elements exist. Preserve query parameters in the watch URL (URLSearchParams(u.search) instead of URLSearchParams()) so that browse page parameters (dir, sort, sortorder, directories) reach handleWatchBrowse for correct hash comparison. Generated using Claude Opus 4.6
1 parent 4f1cef0 commit c7611eb

5 files changed

Lines changed: 156 additions & 61 deletions

File tree

internal/assets/js/browse.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,36 @@ function navModifierHeld(e) {
88
return false;
99
}
1010

11+
// Add relative timestamps ("5m ago") to <time> elements in the browse table.
12+
(function() {
13+
const now = new Date();
14+
const times = document.querySelectorAll('.bull_gen_browse time[datetime]');
15+
for (let i = 0; i < times.length; i++) {
16+
const el = times[i];
17+
const dt = new Date(el.getAttribute('datetime'));
18+
const ago = now - dt;
19+
if (Math.abs(ago) < 5000) {
20+
el.textContent += ' • just now';
21+
continue;
22+
}
23+
if (ago < 0 || ago >= 24 * 60 * 60 * 1000) {
24+
continue;
25+
}
26+
const seconds = Math.floor(ago / 1000);
27+
const minutes = Math.floor(seconds / 60);
28+
const hours = Math.floor(minutes / 60);
29+
let text;
30+
if (hours > 0) {
31+
text = hours + 'h ' + (minutes % 60) + 'm ago';
32+
} else if (minutes > 0) {
33+
text = minutes + 'm ' + (seconds % 60) + 's ago';
34+
} else {
35+
text = seconds + 's ago';
36+
}
37+
el.textContent += ' • ' + text;
38+
}
39+
})();
40+
1141
const rows = document.querySelectorAll('.bull_gen_browse tbody tr');
1242
var selected = 0;
1343
if (rows.length > 0) {

internal/assets/page.html.tmpl

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
<script type="text/javascript">
3838
var u = new URL(document.URL);
3939
u.pathname = '{{ .URLBullPrefix }}watch/' + u.pathname.substr('{{ .URLPrefix }}'.length);
40-
var params = new URLSearchParams();
40+
var params = new URLSearchParams(u.search);
4141
params.set('hash', '{{ .ContentHash }}');
4242
u.search = params.toString();
4343
{{ if (eq .Watch "workaround") }}
@@ -58,6 +58,8 @@
5858
updates.onmessage = function(e) {
5959
//const update = JSON.parse(e.data)
6060
console.log('page changed:', e.data);
61+
updates.close();
62+
updates = undefined;
6163
location.reload();
6264
}
6365
}
@@ -101,7 +103,7 @@
101103
<script src="{{ .URLBullPrefix }}js/itasklist.js?cachebust={{ call .StaticHash "js/itasklist.js" }}" async></script>
102104
{{ end }}
103105
{{ if eq .Page.Class "bull_gen_browse" }}
104-
<script src="{{ .URLBullPrefix }}js/browse.js?cachebust={{ call .StaticHash "js/browse.js" }}" async></script>
106+
<script src="{{ .URLBullPrefix }}js/browse.js?cachebust={{ call .StaticHash "js/browse.js" }}" defer></script>
105107
{{ end }}
106108

107109
</body>

internal/bull/browse.go

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -135,26 +135,15 @@ func (br *browse) browseDirLink(dir string) string {
135135
}).String()
136136
}
137137

138-
func browseTableLine(name string, modTime time.Time, now time.Time) string {
139-
ts := modTime.Format("2006-01-02 15:04:05")
140-
ts += " • " + modTime.Format("Mon")
141-
if ago := now.Sub(modTime); ago >= 0 && ago < 24*time.Hour {
142-
hours := int(ago.Hours())
143-
minutes := int(ago.Minutes()) % 60
144-
seconds := int(ago.Seconds()) % 60
145-
if hours > 0 {
146-
ts += fmt.Sprintf(" • %dh %dm ago", hours, minutes)
147-
} else if minutes > 0 {
148-
ts += fmt.Sprintf(" • %dm %ds ago", minutes, seconds)
149-
} else {
150-
ts += fmt.Sprintf(" • %ds ago", seconds)
151-
}
152-
}
138+
func browseTableLine(name string, modTime time.Time) string {
139+
ts := fmt.Sprintf(`<time datetime="%s">%s • %s</time>`,
140+
modTime.Format(time.RFC3339),
141+
modTime.Format("2006-01-02 15:04:05"),
142+
modTime.Format("Mon"))
153143
return fmt.Sprintf("| %s | %s |\n", name, ts)
154144
}
155145

156146
func (br *browse) browseTable() []string {
157-
now := time.Now()
158147
dirs := br.dirs()
159148
lines := make([]string, 0, len(br.pages))
160149
for _, pg := range br.pages {
@@ -163,7 +152,7 @@ func (br *browse) browseTable() []string {
163152
// This is the first time we encounter a page within this
164153
// directory, so produce a table line for the directory.
165154
name := fmt.Sprintf("[%s/](%s)", dir, br.browseDirLink(dir))
166-
lines = append(lines, browseTableLine(name, latest, now))
155+
lines = append(lines, browseTableLine(name, latest))
167156
dirs[dir] = time.Time{} // still present, but printed
168157
}
169158
if br.directories == "expand" {
@@ -173,12 +162,12 @@ func (br *browse) browseTable() []string {
173162
}
174163
}
175164

176-
lines = append(lines, browseTableLine("[["+pg.PageName+"]]", pg.ModTime, now))
165+
lines = append(lines, browseTableLine("[["+pg.PageName+"]]", pg.ModTime))
177166
}
178167
return lines
179168
}
180169

181-
func (b *bullServer) browse(w http.ResponseWriter, r *http.Request) error {
170+
func (b *bullServer) browseContent(dir, sortby, sortorder, directories string) ([]byte, error) {
182171
// walk the entire content directory
183172
i := newIndexer(b.content)
184173
i.readModTime = true // required for sorting by most recent
@@ -193,21 +182,22 @@ func (b *bullServer) browse(w http.ResponseWriter, r *http.Request) error {
193182
}
194183
})
195184
if err := i.walk(); err != nil {
196-
return err
185+
return nil, err
197186
}
198187
readg.Wait()
199188

189+
urlPrefix := b.URLBullPrefix()
200190
br := browse{
201-
urlPrefix: b.URLBullPrefix(),
202-
dir: r.FormValue("dir"),
203-
sortby: r.FormValue("sort"),
204-
sortorder: r.FormValue("sortorder"),
205-
directories: r.FormValue("directories"),
191+
urlPrefix: urlPrefix,
192+
dir: dir,
193+
sortby: sortby,
194+
sortorder: sortorder,
195+
directories: directories,
206196
pages: pages,
207197
}
208198
br.maybeFilterFilePrefix()
209199
if err := br.sortPages(); err != nil {
210-
return err
200+
return nil, err
211201
}
212202

213203
var buf bytes.Buffer
@@ -217,29 +207,47 @@ func (b *bullServer) browse(w http.ResponseWriter, r *http.Request) error {
217207
fmt.Fprintf(&buf, "# directory browser\n")
218208
}
219209

210+
escDir := url.QueryEscape(br.dir)
211+
escSort := url.QueryEscape(br.sortby)
212+
escOrder := url.QueryEscape(br.sortorder)
213+
220214
fmt.Fprintf(&buf, "subdirectories: ")
221215
if br.directories == "expand" {
222-
fmt.Fprintf(&buf, "[collapse](%sbrowse?dir=%s&sort=%s&sortorder=%s&directories=) • **expand**\n", br.urlPrefix, url.QueryEscape(br.dir), br.sortby, br.sortorder)
216+
fmt.Fprintf(&buf, "[collapse](%sbrowse?dir=%s&sort=%s&sortorder=%s&directories=) • **expand**\n", urlPrefix, escDir, escSort, escOrder)
223217
} else {
224-
fmt.Fprintf(&buf, "**collapse** • [expand](%sbrowse?dir=%s&sort=%s&sortorder=%s&directories=expand)\n", br.urlPrefix, url.QueryEscape(br.dir), br.sortby, br.sortorder)
218+
fmt.Fprintf(&buf, "**collapse** • [expand](%sbrowse?dir=%s&sort=%s&sortorder=%s&directories=expand)\n", urlPrefix, escDir, escSort, escOrder)
225219
}
226220

227-
fmt.Fprintf(&buf, "| page name [↑](%[1]sbrowse?dir=%[2]s&sort=pagename) [↓](%[1]sbrowse?dir=%[2]s&sort=pagename&sortorder=desc) | last modified [↑](%[1]sbrowse?dir=%[2]s&sort=modtime) [↓](%[1]sbrowse?dir=%[2]s&sort=modtime&sortorder=desc) |\n", br.urlPrefix, url.QueryEscape(br.dir))
221+
fmt.Fprintf(&buf, "| page name [↑](%[1]sbrowse?dir=%[2]s&sort=pagename) [↓](%[1]sbrowse?dir=%[2]s&sort=pagename&sortorder=desc) | last modified [↑](%[1]sbrowse?dir=%[2]s&sort=modtime) [↓](%[1]sbrowse?dir=%[2]s&sort=modtime&sortorder=desc) |\n", urlPrefix, url.QueryEscape(br.dir))
228222
fmt.Fprintf(&buf, "|-----------|---------------|\n")
229223
// TODO: link to .. if dir != ""
230224
for _, line := range br.browseTable() {
231225
buf.Write([]byte(line))
232226
}
227+
return buf.Bytes(), nil
228+
}
229+
230+
func (b *bullServer) browse(w http.ResponseWriter, r *http.Request) error {
231+
dir := r.FormValue("dir")
232+
md, err := b.browseContent(
233+
dir,
234+
r.FormValue("sort"),
235+
r.FormValue("sortorder"),
236+
r.FormValue("directories"),
237+
)
238+
if err != nil {
239+
return err
240+
}
233241
pg := &page{
234242
Class: "bull_gen_browse",
235243
Exists: true,
236-
PageName: br.dir,
237-
FileName: page2desired(br.dir),
238-
Content: buf.String(),
244+
PageName: dir,
245+
FileName: page2desired(dir),
246+
Content: string(md),
239247
ModTime: time.Now(),
240248
}
241249
if pg.PageName == "" {
242250
pg.PageName = bullPrefix + "browse"
243251
}
244-
return b.renderMarkdown(w, r, pg, buf.Bytes())
252+
return b.renderMarkdown(w, r, pg, md)
245253
}

internal/bull/browse_test.go

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,51 +8,55 @@ import (
88
)
99

1010
func TestBrowseTableLine(t *testing.T) {
11-
now := time.Date(2026, 3, 22, 15, 0, 0, 0, time.UTC)
12-
1311
tests := []struct {
1412
name string
1513
modTime time.Time
1614
want string
1715
}{
1816
{
19-
name: "seconds ago",
20-
modTime: now.Add(-35 * time.Second),
21-
want: "| [[page]] | 2026-03-22 14:59:25 • Sun • 35s ago |\n",
22-
},
23-
{
24-
name: "minutes and seconds ago",
25-
modTime: now.Add(-5*time.Minute - 10*time.Second),
26-
want: "| [[page]] | 2026-03-22 14:54:50 • Sun • 5m 10s ago |\n",
17+
name: "recent timestamp",
18+
modTime: time.Date(2026, 3, 22, 14, 59, 25, 0, time.UTC),
19+
want: "| [[page]] | <time datetime=\"2026-03-22T14:59:25Z\">2026-03-22 14:59:25 • Sun</time> |\n",
2720
},
2821
{
29-
name: "hours and minutes ago",
30-
modTime: now.Add(-3*time.Hour - 15*time.Minute),
31-
want: "| [[page]] | 2026-03-22 11:45:00 • Sun • 3h 15m ago |\n",
22+
name: "older timestamp",
23+
modTime: time.Date(2026, 3, 19, 15, 0, 0, 0, time.UTC),
24+
want: "| [[page]] | <time datetime=\"2026-03-19T15:00:00Z\">2026-03-19 15:00:00 • Thu</time> |\n",
3225
},
3326
{
34-
name: "exactly 24h ago (no relative)",
35-
modTime: now.Add(-24 * time.Hour),
36-
want: "| [[page]] | 2026-03-21 15:00:00 • Sat |\n",
27+
name: "zero value time",
28+
modTime: time.Time{},
29+
want: "| [[page]] | <time datetime=\"0001-01-01T00:00:00Z\">0001-01-01 00:00:00 • Mon</time> |\n",
3730
},
3831
{
39-
name: "older than 24h (no relative)",
40-
modTime: now.Add(-72 * time.Hour),
41-
want: "| [[page]] | 2026-03-19 15:00:00 • Thu |\n",
32+
name: "non-UTC timezone normalized to RFC3339",
33+
modTime: time.Date(2026, 6, 15, 10, 30, 0, 0, time.FixedZone("CET", 3600)),
34+
want: "| [[page]] | <time datetime=\"2026-06-15T10:30:00+01:00\">2026-06-15 10:30:00 • Mon</time> |\n",
4235
},
4336
{
44-
name: "just now (0s ago)",
45-
modTime: now,
46-
want: "| [[page]] | 2026-03-22 15:00:00 • Sun • 0s ago |\n",
37+
name: "midnight boundary",
38+
modTime: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
39+
want: "| [[page]] | <time datetime=\"2026-01-01T00:00:00Z\">2026-01-01 00:00:00 • Thu</time> |\n",
4740
},
4841
}
4942

5043
for _, tt := range tests {
5144
t.Run(tt.name, func(t *testing.T) {
52-
got := browseTableLine("[[page]]", tt.modTime, now)
45+
got := browseTableLine("[[page]]", tt.modTime)
5346
if diff := cmp.Diff(tt.want, got); diff != "" {
5447
t.Errorf("browseTableLine() mismatch (-want +got):\n%s", diff)
5548
}
5649
})
5750
}
5851
}
52+
53+
func TestBrowseTableLineDeterministic(t *testing.T) {
54+
// browseTableLine must produce identical output for the same input,
55+
// regardless of when it is called. This is required for content hashing.
56+
modTime := time.Date(2026, 3, 22, 14, 59, 25, 0, time.UTC)
57+
first := browseTableLine("[[page]]", modTime)
58+
second := browseTableLine("[[page]]", modTime)
59+
if first != second {
60+
t.Errorf("browseTableLine is not deterministic:\n first: %q\n second: %q", first, second)
61+
}
62+
}

internal/bull/watch.go

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,66 @@ func maybeNotify(ctx context.Context, notify chan<- struct{}, fileName string) {
6565
}
6666
}
6767

68-
func (b *bullServer) handleWatchBrowse(w http.ResponseWriter, flusher http.Flusher, ctx context.Context) error {
68+
func (b *bullServer) browseContentHash(dir, sortby, sortorder, directories string) (string, error) {
69+
md, err := b.browseContent(dir, sortby, sortorder, directories)
70+
if err != nil {
71+
return "", err
72+
}
73+
return hashSum(md), nil
74+
}
75+
76+
func (b *bullServer) handleWatchBrowse(ctx context.Context, w http.ResponseWriter, flusher http.Flusher, r *http.Request) error {
77+
dir := r.FormValue("dir")
78+
sortby := r.FormValue("sort")
79+
sortorder := r.FormValue("sortorder")
80+
directories := r.FormValue("directories")
81+
rhash := r.FormValue("hash")
82+
6983
w.Header().Set("Access-Control-Allow-Origin", "*")
7084
initEventStream(w)
85+
86+
// Acquire the change channel before the hash check to avoid a
87+
// TOCTOU gap: any change that occurs during or after hashing
88+
// will be visible through this channel.
89+
contentChanged := b.contentChangedCh()
90+
91+
// Catch up on changes that happened while disconnected
92+
// (e.g. during page reload), analogous to the hash check
93+
// in the regular page watcher.
94+
if rhash != "" {
95+
current, err := b.browseContentHash(dir, sortby, sortorder, directories)
96+
if err != nil {
97+
log.Printf("browseContentHash (initial): %v", err)
98+
} else if current != rhash {
99+
w.Write([]byte("data: {\"changed\":true}\n\n"))
100+
flusher.Flush()
101+
return nil
102+
}
103+
}
104+
71105
for {
72-
contentChanged := b.contentChangedCh()
73106
select {
74107
case <-ctx.Done():
75108
return ctx.Err()
76109
case <-contentChanged:
110+
// Re-acquire the channel for the next iteration before
111+
// doing any work, so we don't miss changes that occur
112+
// during hash computation.
113+
contentChanged = b.contentChangedCh()
114+
115+
if rhash != "" {
116+
// TODO: browseContentHash walks the entire content
117+
// directory. Consider adding debounce or caching if
118+
// this becomes a bottleneck with large wikis.
119+
current, err := b.browseContentHash(dir, sortby, sortorder, directories)
120+
if err != nil {
121+
log.Printf("browseContentHash (watch loop): %v", err)
122+
// On error, notify the client to reload rather than
123+
// silently sitting idle.
124+
} else if current == rhash {
125+
continue // content didn't actually change for this view
126+
}
127+
}
77128
w.Write([]byte("data: {\"changed\":true}\n\n"))
78129
flusher.Flush()
79130
return nil // client reloads and reconnects
@@ -91,7 +142,7 @@ func (b *bullServer) handleWatch(w http.ResponseWriter, r *http.Request) error {
91142

92143
pageName := pageFromURL(r)
93144
if pageName == bullPrefix+"browse" {
94-
return b.handleWatchBrowse(w, flusher, ctx)
145+
return b.handleWatchBrowse(ctx, w, flusher, r)
95146
}
96147

97148
possibilities := filesFromURL(r)

0 commit comments

Comments
 (0)