diff --git a/client/src/components/SystemNotices/SystemNoticeHost.test.tsx b/client/src/components/SystemNotices/SystemNoticeHost.test.tsx
index 84afad95a..e6db29e82 100644
--- a/client/src/components/SystemNotices/SystemNoticeHost.test.tsx
+++ b/client/src/components/SystemNotices/SystemNoticeHost.test.tsx
@@ -1,4 +1,4 @@
-// FE-W4SNH-001 to FE-W4SNH-008
+// FE-W4SNH-001 to FE-W4SNH-012
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, act } from '../../../tests/helpers/render'
import { useSystemNoticeStore, type SystemNoticeDTO } from '../../store/systemNoticeStore'
@@ -152,4 +152,92 @@ describe('SystemNoticeHost', () => {
expect(screen.getByTestId('modals')).toHaveTextContent('thanks')
})
+ // The deploy window: the server already serves the new release notice, the browser is
+ // still running the bundle the service worker cached, and the keys that notice names do
+ // not exist in it. See hasCopy in SystemNoticeHost.
+ it('FE-W4SNH-009: a notice made only of keys this build cannot resolve waits instead of showing them', () => {
+ useSystemNoticeStore.setState({
+ loaded: true,
+ notices: [notice({
+ id: 'release-notes',
+ display: 'modal',
+ titleKey: 'system_notice.release_notes.title',
+ bodyKey: 'system_notice.release_notes.body',
+ })],
+ })
+
+ render()
+
+ expect(screen.getByTestId('modals')).toBeEmptyDOMElement()
+ })
+
+ it('FE-W4SNH-010: a release notice is judged by the strings its own layout draws', () => {
+ // The release panel never shows the generic title and body, so a resolvable pair there
+ // would vouch for a panel that is still all keys. The keys name a release this build
+ // knows nothing about, which is exactly the position an old bundle is in.
+ const release = {
+ version: '5.0.0',
+ eyebrowKey: 'system_notice.release_notes_5_0_0.eyebrow',
+ headlineKey: 'system_notice.release_notes_5_0_0.headline',
+ introKey: 'system_notice.release_notes_5_0_0.intro',
+ featuresLabelKey: 'system_notice.release_notes_5_0_0.features_label',
+ features: [],
+ note: {
+ eyebrowKey: 'system_notice.release_notes_5_0_0.note_eyebrow',
+ titleKey: 'system_notice.release_notes_5_0_0.note_title',
+ bodyKey: 'system_notice.release_notes_5_0_0.note_body',
+ promiseLabelKey: 'system_notice.release_notes_5_0_0.promise_label',
+ promiseLeadKey: 'system_notice.release_notes_5_0_0.promise_lead',
+ promiseTextKey: 'system_notice.release_notes_5_0_0.promise_text',
+ bodyAfterKey: 'system_notice.release_notes_5_0_0.note_body_after',
+ closingKey: 'system_notice.release_notes_5_0_0.note_closing',
+ },
+ supportLeadKey: 'system_notice.release_notes_5_0_0.support_lead',
+ supportTextKey: 'system_notice.release_notes_5_0_0.support_text',
+ }
+ useSystemNoticeStore.setState({
+ loaded: true,
+ notices: [notice({ id: 'release-notes', display: 'modal', titleKey: 'Update', bodyKey: 'Installed', release })],
+ })
+
+ render()
+
+ expect(screen.getByTestId('modals')).toBeEmptyDOMElement()
+ })
+
+ it('FE-W4SNH-011: a notice written out in words is shown, keys or no keys', () => {
+ // An instance's own announcement carries literal copy, and t() hands back anything it
+ // does not know — so the shape of the string is what tells the two apart.
+ useSystemNoticeStore.setState({
+ loaded: true,
+ notices: [notice({
+ id: 'outage',
+ display: 'modal',
+ titleKey: 'Maintenance tonight',
+ bodyKey: 'The server restarts at 22:00.',
+ })],
+ })
+
+ render()
+
+ expect(screen.getByTestId('modals')).toHaveTextContent('outage')
+ })
+
+ it('FE-W4SNH-012: one resolved string is enough to show the notice', () => {
+ // Generous on purpose: half a translation is still readable, and a notice hidden by
+ // mistake is the worse failure. 'common.save' is in every build.
+ useSystemNoticeStore.setState({
+ loaded: true,
+ notices: [notice({
+ id: 'half',
+ display: 'modal',
+ titleKey: 'common.save',
+ bodyKey: 'system_notice.not.in.this.build',
+ })],
+ })
+
+ render()
+
+ expect(screen.getByTestId('modals')).toHaveTextContent('half')
+ })
})
diff --git a/client/src/components/SystemNotices/SystemNoticeHost.tsx b/client/src/components/SystemNotices/SystemNoticeHost.tsx
index 8c86040a7..d622d3148 100644
--- a/client/src/components/SystemNotices/SystemNoticeHost.tsx
+++ b/client/src/components/SystemNotices/SystemNoticeHost.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
-import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
+import { useSystemNoticeStore, type SystemNoticeDTO } from '../../store/systemNoticeStore.js';
+import { useTranslation } from '../../i18n/index.js';
import { ModalRenderer } from './SystemNoticeModal.js';
import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js';
@@ -18,8 +19,44 @@ function useIsMobile() {
return isMobile;
}
+/**
+ * A key by its shape: dotted, no spaces, `system_notice.release_notes.headline`.
+ *
+ * A notice may carry literal copy instead of a key — an instance's own announcement is
+ * written out, not translated — and `t()` hands any string it does not know straight back,
+ * so the test below cannot tell "no translation" from "this IS the text" on its own. The
+ * shape can: a sentence has spaces and no dots between words.
+ */
+const KEY_SHAPE = /^[a-z0-9_-]+(\.[a-z0-9_-]+)+$/i;
+
+/**
+ * Whether a notice has words to show, rather than the names of strings this build lacks.
+ *
+ * The keys come from the server's registry, the copy from the bundle in the browser, and a
+ * deploy parts the two for as long as it takes the page to reload: the service worker keeps
+ * serving the old app while `/api/config` already reports the new version, and the release
+ * notice for that very version arrives naming keys the old bundle never had. What is drawn
+ * is then `system_notice.release_notes.headline` where the headline belongs, on the one
+ * notice whose whole job is to be read once per release. Worse, a dismissal in that window
+ * is a real one: it is `per-version`, so whoever closes the gibberish never sees the notes.
+ *
+ * So a notice made only of unresolved keys waits. Nothing is spent by waiting — a notice is
+ * used up by `POST /dismiss` and by nothing else — and `App.tsx` reloads the page as soon
+ * as the new worker takes over, after which this returns true and the modal opens for real.
+ *
+ * Deliberately generous: one resolved key is enough. Half a translation is still readable,
+ * and a notice hidden by mistake is a worse failure than an ugly one.
+ */
+function hasCopy(notice: SystemNoticeDTO, t: (key: string) => string): boolean {
+ const keys = notice.release
+ ? [notice.release.headlineKey, notice.release.introKey, notice.release.note.titleKey]
+ : [notice.titleKey, notice.bodyKey];
+ return keys.some(key => !KEY_SHAPE.test(key) || t(key) !== key);
+}
+
export function SystemNoticeHost() {
const { notices, loaded } = useSystemNoticeStore();
+ const { t } = useTranslation();
const isMobile = useIsMobile();
// Notices are fetched by authStore after login (see App.tsx / authStore modification).
@@ -33,8 +70,10 @@ export function SystemNoticeHost() {
if (!loaded) return null;
- // desktopOnly notices (e.g. the thank-you/support modal) are hidden on mobile.
- const visible = isMobile ? notices.filter(n => !n.desktopOnly) : notices;
+ // desktopOnly notices (e.g. the thank-you/support modal) are hidden on mobile, and a
+ // notice whose copy this build does not have yet waits for the reload (see hasCopy).
+ const readable = notices.filter(n => hasCopy(n, t));
+ const visible = isMobile ? readable.filter(n => !n.desktopOnly) : readable;
const modals = visible.filter(n => n.display === 'modal');
const banners = visible.filter(n => n.display === 'banner');