No service worker, and nothing cached by name
Four deploys today, and every one of them ended with "hard-reload first" -- twice a working fix read as broken because the page was the previous bundle. That is not a thing to keep apologising for in a message; it is a bug in what we serve. Two causes, both dealt with here. Flutter installs a service worker for offline use, and this app has no offline: it is a window onto a live IRC connection, so a cached copy of it can show nothing anybody wants. What it did instead was hold a deploy back -- a new worker downloads, but the spec leaves it *waiting* until every tab on the origin is closed, so reloading kept serving the old one and the only cure was clearing the site data by hand. `--pwa-strategy=none` in both places that build the bundle. It also retires the workers already installed: with no `flutter_service_worker.js` left to fetch, a browser update check 404s and drops the registration. Verified in a browser against the built bundle -- no registrations, no controller, no caches. And `tools/webserve.py` asks for revalidation on everything it serves. The usual rule is to cache hashed assets forever and revalidate the few stable names, but nothing in this bundle is hashed: `main.dart.js`, `frq_core.js`, `flutter_bootstrap.js` all keep their names and change their contents on every deploy, so a name says nothing about what is behind it. `no-cache` is not `no-store` -- the copy is kept, and an unchanged file comes back 304 with no body. One thing found on the way, and it had been failing silently: the build recipe is a single-quoted `bash -c`, and the apostrophe in a comment I put inside it ended the string. `flutter build web` never ran and `just build web` still exited 0. The comment lives outside the quotes now, with a note saying why. Two tests: the served headers and their 304, and the fields that ask the keyboard for no help -- the tree carrying a `verbatim` prop proves nothing if the renderer drops it, which it did when reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
364ea65 parent: fc42e9b modified
.modal/web/Dockerfile +9 -4 | @@ -29,7 +29,10 @@ WORKDIR /src | ||
| 29 | 29 | COPY . . |
| 30 | 30 | |
| 31 | 31 | # The same two steps as `just build web`, inlined because `just` is not here |
| 32 | -# and is not worth an install for one call. The core is compiled to | |
| 32 | +# and is not worth an install for one call. Inlined means duplicated, so the | |
| 33 | +# flags have to be kept in step with that recipe -- `--pwa-strategy=none` | |
| 34 | +# below is one of them, and the deployment is the place it matters most, | |
| 35 | +# being the one people reach for by URL. The core is compiled to | |
| 33 | 36 | # JavaScript and copied into `flutter/web/` rather than into the output, |
| 34 | 37 | # because `flutter build web` copies that directory into the bundle -- which |
| 35 | 38 | # is what makes the page's `<script src="frq_core.js">` resolve the same |
| @@ -47,12 +50,14 @@ RUN tools/toolchain.sh exec -- bash -euo pipefail -c '\ | ||
| 47 | 50 | nim js -d:release --hints:off --path:src --path:web \ |
| 48 | 51 | --out:/src/build/web/frq_core.js web/frq_web.nim && \ |
| 49 | 52 | cp /src/build/web/frq_core.js /src/flutter/web/frq_core.js && \ |
| 50 | - cd /src/flutter && flutter pub get && flutter build web' | |
| 53 | + cd /src/flutter && flutter pub get && \ | |
| 54 | + flutter build web --pwa-strategy=none' | |
| 51 | 55 | |
| 52 | 56 | # python:*-slim and not debian:*-slim, for two reasons that happen to agree: |
| 53 | 57 | # Modal runs its own client inside the container, so the image needs a Python |
| 54 | -# it can use, and the server is `http.server` -- the same one `just run web` | |
| 55 | -# starts on localhost, so what is served here is served the same way there. | |
| 58 | +# it can use, and the server is `tools/webserve.py` -- the same one | |
| 59 | +# `just run web` starts on localhost, so what is served here is served the | |
| 60 | +# same way there. | |
| 56 | 61 | FROM python:3.13-slim |
| 57 | 62 | |
| 58 | 63 | COPY --from=build /src/flutter/build/web /srv/web |
| @@ -29,7 +29,10 @@ WORKDIR /src | |||
| 29 | COPY . . | 29 | COPY . . |
| 30 | 30 | ||
| 31 | # The same two steps as `just build web`, inlined because `just` is not here | 31 | # The same two steps as `just build web`, inlined because `just` is not here |
| 32 | -# and is not worth an install for one call. The core is compiled to | 32 | +# and is not worth an install for one call. Inlined means duplicated, so the |
| 33 | +# flags have to be kept in step with that recipe -- `--pwa-strategy=none` | ||
| 34 | +# below is one of them, and the deployment is the place it matters most, | ||
| 35 | +# being the one people reach for by URL. The core is compiled to | ||
| 33 | # JavaScript and copied into `flutter/web/` rather than into the output, | 36 | # JavaScript and copied into `flutter/web/` rather than into the output, |
| 34 | # because `flutter build web` copies that directory into the bundle -- which | 37 | # because `flutter build web` copies that directory into the bundle -- which |
| 35 | # is what makes the page's `<script src="frq_core.js">` resolve the same | 38 | # is what makes the page's `<script src="frq_core.js">` resolve the same |
| @@ -47,12 +50,14 @@ RUN tools/toolchain.sh exec -- bash -euo pipefail -c '\ | |||
| 47 | nim js -d:release --hints:off --path:src --path:web \ | 50 | nim js -d:release --hints:off --path:src --path:web \ |
| 48 | --out:/src/build/web/frq_core.js web/frq_web.nim && \ | 51 | --out:/src/build/web/frq_core.js web/frq_web.nim && \ |
| 49 | cp /src/build/web/frq_core.js /src/flutter/web/frq_core.js && \ | 52 | cp /src/build/web/frq_core.js /src/flutter/web/frq_core.js && \ |
| 50 | - cd /src/flutter && flutter pub get && flutter build web' | 53 | + cd /src/flutter && flutter pub get && \ |
| 54 | + flutter build web --pwa-strategy=none' | ||
| 51 | 55 | ||
| 52 | # python:*-slim and not debian:*-slim, for two reasons that happen to agree: | 56 | # python:*-slim and not debian:*-slim, for two reasons that happen to agree: |
| 53 | # Modal runs its own client inside the container, so the image needs a Python | 57 | # Modal runs its own client inside the container, so the image needs a Python |
| 54 | -# it can use, and the server is `http.server` -- the same one `just run web` | 58 | +# it can use, and the server is `tools/webserve.py` -- the same one |
| 55 | -# starts on localhost, so what is served here is served the same way there. | 59 | +# `just run web` starts on localhost, so what is served here is served the |
| 60 | +# same way there. | ||
| 56 | FROM python:3.13-slim | 61 | FROM python:3.13-slim |
| 57 | 62 | ||
| 58 | COPY --from=build /src/flutter/build/web /srv/web | 63 | COPY --from=build /src/flutter/build/web /srv/web |
modified
flutter/test/nim_layout_test.dart +22 -0 | @@ -618,4 +618,26 @@ void main() { | ||
| 618 | 618 | reason: 'the arrow button is back'); |
| 619 | 619 | }); |
| 620 | 620 | }); |
| 621 | + | |
| 622 | + group('a field holding an identifier', () { | |
| 623 | + testWidgets('asks the keyboard for no help at all', (tester) async { | |
| 624 | + // A phone keyboard puts a space after a full stop, because a full stop | |
| 625 | + // ends a sentence — and is also the middle of `alice.bsky.social`. | |
| 626 | + // These are the properties that turn that off; the tree carrying a | |
| 627 | + // `verbatim` prop proves nothing if the renderer drops it. | |
| 628 | + core.dispatch('screen.connect'); | |
| 629 | + core.dispatch('auth.mode:bluesky'); | |
| 630 | + await layOut(tester, sizes['phone']!); | |
| 631 | + | |
| 632 | + final fields = tester.widgetList<TextField>(find.byType(TextField)); | |
| 633 | + expect(fields, isNotEmpty); | |
| 634 | + for (final f in fields) { | |
| 635 | + expect(f.autocorrect, isFalse, reason: 'autocorrect is on'); | |
| 636 | + expect(f.enableSuggestions, isFalse, reason: 'suggestions are on'); | |
| 637 | + expect(f.textCapitalization, TextCapitalization.none); | |
| 638 | + expect(f.keyboardType, TextInputType.url); | |
| 639 | + } | |
| 640 | + expectLaidOut(tester, 'the connect fields'); | |
| 641 | + }); | |
| 642 | + }); | |
| 621 | 643 | } |
| @@ -618,4 +618,26 @@ void main() { | |||
| 618 | reason: 'the arrow button is back'); | 618 | reason: 'the arrow button is back'); |
| 619 | }); | 619 | }); |
| 620 | }); | 620 | }); |
| 621 | + | ||
| 622 | + group('a field holding an identifier', () { | ||
| 623 | + testWidgets('asks the keyboard for no help at all', (tester) async { | ||
| 624 | + // A phone keyboard puts a space after a full stop, because a full stop | ||
| 625 | + // ends a sentence — and is also the middle of `alice.bsky.social`. | ||
| 626 | + // These are the properties that turn that off; the tree carrying a | ||
| 627 | + // `verbatim` prop proves nothing if the renderer drops it. | ||
| 628 | + core.dispatch('screen.connect'); | ||
| 629 | + core.dispatch('auth.mode:bluesky'); | ||
| 630 | + await layOut(tester, sizes['phone']!); | ||
| 631 | + | ||
| 632 | + final fields = tester.widgetList<TextField>(find.byType(TextField)); | ||
| 633 | + expect(fields, isNotEmpty); | ||
| 634 | + for (final f in fields) { | ||
| 635 | + expect(f.autocorrect, isFalse, reason: 'autocorrect is on'); | ||
| 636 | + expect(f.enableSuggestions, isFalse, reason: 'suggestions are on'); | ||
| 637 | + expect(f.textCapitalization, TextCapitalization.none); | ||
| 638 | + expect(f.keyboardType, TextInputType.url); | ||
| 639 | + } | ||
| 640 | + expectLaidOut(tester, 'the connect fields'); | ||
| 641 | + }); | ||
| 642 | + }); | ||
| 621 | } | 643 | } |
modified
justfile +17 -1 | @@ -194,10 +194,26 @@ _web-bundle: | ||
| 194 | 194 | # is `localhost` only when it is. Guest works everywhere. |
| 195 | 195 | python3 tools/client-metadata.py "${FRQ_WEB_ORIGIN:-http://localhost:8000}" \ |
| 196 | 196 | > flutter/web/client-metadata.json |
| 197 | + # No service worker, via `--pwa-strategy=none` below. Flutter installs | |
| 198 | + # one for offline use and this app has no offline: it is a window onto a | |
| 199 | + # live IRC connection, so a cached copy of it can show nothing anybody | |
| 200 | + # wants. What the worker did instead was hold a deploy back -- a new one | |
| 201 | + # downloads, but the spec leaves it *waiting* until every tab on the | |
| 202 | + # origin is closed, so reloading kept serving the previous bundle and | |
| 203 | + # the only cure was clearing the site data by hand. Twice in one | |
| 204 | + # afternoon a fix looked broken because of it. | |
| 205 | + # | |
| 206 | + # It also retires the workers already installed out there: with no | |
| 207 | + # `flutter_service_worker.js` left to fetch, a browser update check 404s | |
| 208 | + # and the registration is dropped. | |
| 209 | + # | |
| 210 | + # The comment lives out here rather than inside the quoted script below, | |
| 211 | + # which is single-quoted -- an apostrophe in there ends the string, and | |
| 212 | + # the build silently stopped after `pub get` and still exited 0. | |
| 197 | 213 | exec "{{tc}}" exec -- bash -euo pipefail -c ' |
| 198 | 214 | cd flutter |
| 199 | 215 | flutter pub get |
| 200 | - flutter build web | |
| 216 | + flutter build web --pwa-strategy=none | |
| 201 | 217 | rm -rf ../build/web |
| 202 | 218 | cp -r build/web ../build/web |
| 203 | 219 | echo "built build/web"' |
| @@ -194,10 +194,26 @@ _web-bundle: | |||
| 194 | # is `localhost` only when it is. Guest works everywhere. | 194 | # is `localhost` only when it is. Guest works everywhere. |
| 195 | python3 tools/client-metadata.py "${FRQ_WEB_ORIGIN:-http://localhost:8000}" \ | 195 | python3 tools/client-metadata.py "${FRQ_WEB_ORIGIN:-http://localhost:8000}" \ |
| 196 | > flutter/web/client-metadata.json | 196 | > flutter/web/client-metadata.json |
| 197 | + # No service worker, via `--pwa-strategy=none` below. Flutter installs | ||
| 198 | + # one for offline use and this app has no offline: it is a window onto a | ||
| 199 | + # live IRC connection, so a cached copy of it can show nothing anybody | ||
| 200 | + # wants. What the worker did instead was hold a deploy back -- a new one | ||
| 201 | + # downloads, but the spec leaves it *waiting* until every tab on the | ||
| 202 | + # origin is closed, so reloading kept serving the previous bundle and | ||
| 203 | + # the only cure was clearing the site data by hand. Twice in one | ||
| 204 | + # afternoon a fix looked broken because of it. | ||
| 205 | + # | ||
| 206 | + # It also retires the workers already installed out there: with no | ||
| 207 | + # `flutter_service_worker.js` left to fetch, a browser update check 404s | ||
| 208 | + # and the registration is dropped. | ||
| 209 | + # | ||
| 210 | + # The comment lives out here rather than inside the quoted script below, | ||
| 211 | + # which is single-quoted -- an apostrophe in there ends the string, and | ||
| 212 | + # the build silently stopped after `pub get` and still exited 0. | ||
| 197 | exec "{{tc}}" exec -- bash -euo pipefail -c ' | 213 | exec "{{tc}}" exec -- bash -euo pipefail -c ' |
| 198 | cd flutter | 214 | cd flutter |
| 199 | flutter pub get | 215 | flutter pub get |
| 200 | - flutter build web | 216 | + flutter build web --pwa-strategy=none |
| 201 | rm -rf ../build/web | 217 | rm -rf ../build/web |
| 202 | cp -r build/web ../build/web | 218 | cp -r build/web ../build/web |
| 203 | echo "built build/web"' | 219 | echo "built build/web"' |
modified
nim/web/test/serve.py +21 -0 | @@ -81,6 +81,27 @@ def main(): | ||
| 81 | 81 | assert b"across the origin boundary" in r.read() |
| 82 | 82 | ok("it still serves the bundle") |
| 83 | 83 | |
| 84 | + # Every name in this bundle is stable and its contents change on | |
| 85 | + # every deploy, so a cached copy is a stale one and the browser has | |
| 86 | + # no way to know. `no-cache` keeps the copy and revalidates it. | |
| 87 | + with urllib.request.urlopen(base + "/serve.py", timeout=5) as r: | |
| 88 | + assert r.headers.get("Cache-Control") == "no-cache", \ | |
| 89 | + r.headers.get("Cache-Control") | |
| 90 | + ok("and asks the browser to revalidate what it serves") | |
| 91 | + | |
| 92 | + # A conditional request still answers 304, which is the whole reason | |
| 93 | + # this costs a round trip rather than a download. | |
| 94 | + req = urllib.request.Request(base + "/serve.py", headers={ | |
| 95 | + "If-Modified-Since": r.headers.get("Last-Modified")}) | |
| 96 | + code = 0 | |
| 97 | + try: | |
| 98 | + with urllib.request.urlopen(req, timeout=5) as r2: | |
| 99 | + code = r2.status | |
| 100 | + except urllib.error.HTTPError as e: | |
| 101 | + code = e.code | |
| 102 | + assert code == 304, code | |
| 103 | + ok("and an unchanged file comes back 304, with no body") | |
| 104 | + | |
| 84 | 105 | # The relay, with the body and its multipart boundary intact -- |
| 85 | 106 | # without the Content-Type the body is unreadable at the far end. |
| 86 | 107 | status, body = post(base + "/api/v1/upload", b"--b\r\npicture\r\n--b--", |
| @@ -81,6 +81,27 @@ def main(): | |||
| 81 | assert b"across the origin boundary" in r.read() | 81 | assert b"across the origin boundary" in r.read() |
| 82 | ok("it still serves the bundle") | 82 | ok("it still serves the bundle") |
| 83 | 83 | ||
| 84 | + # Every name in this bundle is stable and its contents change on | ||
| 85 | + # every deploy, so a cached copy is a stale one and the browser has | ||
| 86 | + # no way to know. `no-cache` keeps the copy and revalidates it. | ||
| 87 | + with urllib.request.urlopen(base + "/serve.py", timeout=5) as r: | ||
| 88 | + assert r.headers.get("Cache-Control") == "no-cache", \ | ||
| 89 | + r.headers.get("Cache-Control") | ||
| 90 | + ok("and asks the browser to revalidate what it serves") | ||
| 91 | + | ||
| 92 | + # A conditional request still answers 304, which is the whole reason | ||
| 93 | + # this costs a round trip rather than a download. | ||
| 94 | + req = urllib.request.Request(base + "/serve.py", headers={ | ||
| 95 | + "If-Modified-Since": r.headers.get("Last-Modified")}) | ||
| 96 | + code = 0 | ||
| 97 | + try: | ||
| 98 | + with urllib.request.urlopen(req, timeout=5) as r2: | ||
| 99 | + code = r2.status | ||
| 100 | + except urllib.error.HTTPError as e: | ||
| 101 | + code = e.code | ||
| 102 | + assert code == 304, code | ||
| 103 | + ok("and an unchanged file comes back 304, with no body") | ||
| 104 | + | ||
| 84 | # The relay, with the body and its multipart boundary intact -- | 105 | # The relay, with the body and its multipart boundary intact -- |
| 85 | # without the Content-Type the body is unreadable at the far end. | 106 | # without the Content-Type the body is unreadable at the far end. |
| 86 | status, body = post(base + "/api/v1/upload", b"--b\r\npicture\r\n--b--", | 107 | status, body = post(base + "/api/v1/upload", b"--b\r\npicture\r\n--b--", |
modified
tools/webserve.py +26 -0 | @@ -45,6 +45,32 @@ LIMIT = 12 * 1024 * 1024 | ||
| 45 | 45 | |
| 46 | 46 | |
| 47 | 47 | class Handler(SimpleHTTPRequestHandler): |
| 48 | + def end_headers(self): | |
| 49 | + """Revalidate everything, because no name here promises anything. | |
| 50 | + | |
| 51 | + A deploy changed what was served and browsers went on showing what | |
| 52 | + they had -- twice in one afternoon a fix looked broken because the | |
| 53 | + page was yesterday's. Flutter stamps a fresh `serviceWorkerVersion` | |
| 54 | + so the new worker does download, but the spec leaves it *waiting* | |
| 55 | + until every tab on the origin is closed; reloading does not do it, | |
| 56 | + and the cure was clearing the site's data by hand. | |
| 57 | + | |
| 58 | + The usual answer -- cache the hashed assets forever, revalidate the | |
| 59 | + few files with stable names -- does not apply, because nothing in | |
| 60 | + this bundle is hashed. `main.dart.js`, `frq_core.js`, | |
| 61 | + `flutter_bootstrap.js` and the rest keep their names and change | |
| 62 | + their contents on every deploy, so a name here says nothing about | |
| 63 | + what is behind it. | |
| 64 | + | |
| 65 | + `no-cache` is not `no-store`: the copy is kept and revalidated, so | |
| 66 | + an unchanged file costs a conditional GET and comes back 304 with | |
| 67 | + no body. A round trip per file, against a fix that does not arrive. | |
| 68 | + """ | |
| 69 | + path = self.path.split("?")[0] | |
| 70 | + if not path.startswith(UPLOAD): | |
| 71 | + self.send_header("Cache-Control", "no-cache") | |
| 72 | + SimpleHTTPRequestHandler.end_headers(self) | |
| 73 | + | |
| 48 | 74 | def do_POST(self): |
| 49 | 75 | if self.path.split("?")[0] != UPLOAD: |
| 50 | 76 | self.send_error(404, "Not Found") |
| @@ -45,6 +45,32 @@ LIMIT = 12 * 1024 * 1024 | |||
| 45 | 45 | ||
| 46 | 46 | ||
| 47 | class Handler(SimpleHTTPRequestHandler): | 47 | class Handler(SimpleHTTPRequestHandler): |
| 48 | + def end_headers(self): | ||
| 49 | + """Revalidate everything, because no name here promises anything. | ||
| 50 | + | ||
| 51 | + A deploy changed what was served and browsers went on showing what | ||
| 52 | + they had -- twice in one afternoon a fix looked broken because the | ||
| 53 | + page was yesterday's. Flutter stamps a fresh `serviceWorkerVersion` | ||
| 54 | + so the new worker does download, but the spec leaves it *waiting* | ||
| 55 | + until every tab on the origin is closed; reloading does not do it, | ||
| 56 | + and the cure was clearing the site's data by hand. | ||
| 57 | + | ||
| 58 | + The usual answer -- cache the hashed assets forever, revalidate the | ||
| 59 | + few files with stable names -- does not apply, because nothing in | ||
| 60 | + this bundle is hashed. `main.dart.js`, `frq_core.js`, | ||
| 61 | + `flutter_bootstrap.js` and the rest keep their names and change | ||
| 62 | + their contents on every deploy, so a name here says nothing about | ||
| 63 | + what is behind it. | ||
| 64 | + | ||
| 65 | + `no-cache` is not `no-store`: the copy is kept and revalidated, so | ||
| 66 | + an unchanged file costs a conditional GET and comes back 304 with | ||
| 67 | + no body. A round trip per file, against a fix that does not arrive. | ||
| 68 | + """ | ||
| 69 | + path = self.path.split("?")[0] | ||
| 70 | + if not path.startswith(UPLOAD): | ||
| 71 | + self.send_header("Cache-Control", "no-cache") | ||
| 72 | + SimpleHTTPRequestHandler.end_headers(self) | ||
| 73 | + | ||
| 48 | def do_POST(self): | 74 | def do_POST(self): |
| 49 | if self.path.split("?")[0] != UPLOAD: | 75 | if self.path.split("?")[0] != UPLOAD: |
| 50 | self.send_error(404, "Not Found") | 76 | self.send_error(404, "Not Found") |