@open-mercato/ui 0.9.1-develop.7459.1.99e3accd30 → 0.9.1-develop.7464.1.9a392fca41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -24,8 +24,26 @@ function isSameOriginFlashNavigation() {
|
|
|
24
24
|
return false;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
const PENDING_FLASH_MAX_AGE_MS = 5e3;
|
|
28
|
+
let listeningFlashHostCount = 0;
|
|
29
|
+
let pendingFlash = null;
|
|
30
|
+
function readPendingFlash() {
|
|
31
|
+
const pending = pendingFlash;
|
|
32
|
+
if (!pending) return null;
|
|
33
|
+
if (Date.now() - pending.queuedAt > PENDING_FLASH_MAX_AGE_MS) {
|
|
34
|
+
pendingFlash = null;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
if (pendingFlash === pending) pendingFlash = null;
|
|
39
|
+
}, 0);
|
|
40
|
+
return pending;
|
|
41
|
+
}
|
|
27
42
|
function flash(message, type = "info") {
|
|
28
43
|
if (typeof window === "undefined") return;
|
|
44
|
+
if (listeningFlashHostCount === 0) {
|
|
45
|
+
pendingFlash = { message, type, queuedAt: Date.now() };
|
|
46
|
+
}
|
|
29
47
|
const evt = new CustomEvent("flash", { detail: { message, type } });
|
|
30
48
|
window.dispatchEvent(evt);
|
|
31
49
|
}
|
|
@@ -170,7 +188,13 @@ function FlashMessagesInner() {
|
|
|
170
188
|
showFlash(text, t);
|
|
171
189
|
};
|
|
172
190
|
window.addEventListener("flash", handler);
|
|
173
|
-
|
|
191
|
+
listeningFlashHostCount += 1;
|
|
192
|
+
const pending = readPendingFlash();
|
|
193
|
+
if (pending) showFlash(pending.message, pending.type);
|
|
194
|
+
return () => {
|
|
195
|
+
listeningFlashHostCount -= 1;
|
|
196
|
+
window.removeEventListener("flash", handler);
|
|
197
|
+
};
|
|
174
198
|
}, [showFlash]);
|
|
175
199
|
const handleDismiss = React.useCallback(() => {
|
|
176
200
|
clearDismissTimer();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/backend/FlashMessages.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { Alert, type AlertStatus } from '../primitives/alert'\n\nexport type FlashKind = 'success' | 'error' | 'warning' | 'info'\n\nconst flashKindToAlertStatus: Record<FlashKind, AlertStatus> = {\n success: 'success',\n error: 'error',\n warning: 'warning',\n info: 'information',\n}\n\nfunction normalizeFlashKind(value: string | null | undefined): FlashKind {\n if (value && Object.prototype.hasOwnProperty.call(flashKindToAlertStatus, value)) {\n return value as FlashKind\n }\n return 'success'\n}\n\n// A URL-supplied flash is only trustworthy when the navigation that carried it\n// originated from the same origin (an in-app POST->GET redirect or client-side\n// navigation). Cross-origin links \u2014 the phishing/content-spoofing vector \u2014 must\n// not be allowed to render attacker-controlled copy inside an authoritative banner.\nfunction isSameOriginFlashNavigation(): boolean {\n if (typeof window === 'undefined') return false\n const referrer = document.referrer\n // No referrer (direct load, bookmark, or referrer stripped) is ambiguous, not a\n // cross-origin redirect we can attribute to an attacker page; allow it.\n if (!referrer) return true\n try {\n return new URL(referrer).origin === window.location.origin\n } catch {\n return false\n }\n}\n\n// Programmatic API to show a flash message without navigation.\n// Consumers can import { flash } and call flash('text', 'error').\nexport function flash(message: string, type: FlashKind = 'info') {\n if (typeof window === 'undefined') return\n const evt = new CustomEvent('flash', { detail: { message, type } })\n window.dispatchEvent(evt)\n}\n\n// Two hosts mount `<FlashMessages />` over the same page: `FrontendLayout`\n// wraps the entire app (backend routes included, via `AppProviders`) and\n// `AppShell` mounts its own copy inside `<main>`. Both listen to the same\n// `flash` event, so every backend toast rendered twice \u2014 two stacked fixed\n// overlays, each with its own dismiss button, so dismissing one left the other\n// behind. Both mount points are public surfaces third-party apps rely on, so\n// instead of deleting one, only the first host to mount renders; the others\n// keep their state in sync and take over if it unmounts.\nconst mountedFlashHostIds: symbol[] = []\nconst flashHostListeners = new Set<() => void>()\n\nfunction notifyFlashHostListeners() {\n for (const listener of flashHostListeners) listener()\n}\n\nfunction subscribeFlashHosts(listener: () => void): () => void {\n flashHostListeners.add(listener)\n return () => {\n flashHostListeners.delete(listener)\n }\n}\n\nfunction getPrimaryFlashHostId(): symbol | null {\n return mountedFlashHostIds[0] ?? null\n}\n\nfunction getServerFlashHostId(): symbol | null {\n return null\n}\n\nfunction useIsPrimaryFlashHost(): boolean {\n const hostIdRef = React.useRef<symbol | null>(null)\n if (hostIdRef.current === null) hostIdRef.current = Symbol('om-flash-host')\n const hostId = hostIdRef.current\n\n React.useEffect(() => {\n mountedFlashHostIds.push(hostId)\n notifyFlashHostListeners()\n return () => {\n const index = mountedFlashHostIds.indexOf(hostId)\n if (index >= 0) mountedFlashHostIds.splice(index, 1)\n notifyFlashHostListeners()\n }\n }, [hostId])\n\n return React.useSyncExternalStore(subscribeFlashHosts, getPrimaryFlashHostId, getServerFlashHostId) === hostId\n}\n\ntype HistoryMethod = History['pushState']\n\nfunction useLocationKey() {\n const [locationKey, setLocationKey] = React.useState(() => {\n if (typeof window === 'undefined') return ''\n return window.location.href\n })\n const locationKeyRef = React.useRef(locationKey)\n\n React.useEffect(() => {\n locationKeyRef.current = locationKey\n }, [locationKey])\n\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n\n let active = true\n const scheduleUpdate = (href: string) => {\n const run = () => {\n if (!active) return\n if (locationKeyRef.current === href) return\n locationKeyRef.current = href\n setLocationKey(href)\n }\n if (typeof queueMicrotask === 'function') {\n queueMicrotask(run)\n } else {\n setTimeout(run, 0)\n }\n }\n const updateLocation = () => {\n if (!active) return\n const href = window.location.href\n if (href === locationKeyRef.current) return\n scheduleUpdate(href)\n }\n\n const deferredUpdateLocation = () => {\n setTimeout(updateLocation, 0)\n }\n\n const originalPush: HistoryMethod = window.history.pushState.bind(window.history)\n const originalReplace: HistoryMethod = window.history.replaceState.bind(window.history)\n\n const pushState: HistoryMethod = (...args) => {\n originalPush(...args)\n deferredUpdateLocation()\n }\n\n const replaceState: HistoryMethod = (...args) => {\n originalReplace(...args)\n deferredUpdateLocation()\n }\n\n window.history.pushState = pushState\n window.history.replaceState = replaceState\n window.addEventListener('popstate', updateLocation)\n window.addEventListener('hashchange', updateLocation)\n updateLocation()\n\n return () => {\n active = false\n window.history.pushState = originalPush\n window.history.replaceState = originalReplace\n window.removeEventListener('popstate', updateLocation)\n window.removeEventListener('hashchange', updateLocation)\n }\n }, [])\n\n return locationKey\n}\n\nfunction FlashMessagesInner() {\n const [msg, setMsg] = React.useState<string | null>(null)\n const [kind, setKind] = React.useState<FlashKind>('info')\n const isPrimaryHost = useIsPrimaryFlashHost()\n const locationKey = useLocationKey()\n const dismissTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n\n const clearDismissTimer = React.useCallback(() => {\n if (dismissTimerRef.current) {\n clearTimeout(dismissTimerRef.current)\n dismissTimerRef.current = null\n }\n }, [])\n\n const showFlash = React.useCallback((message: string, type: FlashKind) => {\n clearDismissTimer()\n setMsg(message)\n setKind(type)\n dismissTimerRef.current = setTimeout(() => {\n dismissTimerRef.current = null\n setMsg(null)\n }, 3000)\n }, [clearDismissTimer])\n\n React.useEffect(() => {\n return () => {\n clearDismissTimer()\n }\n }, [clearDismissTimer])\n\n // Read flash from URL on any navigation change (client-side too)\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n const url = new URL(window.location.href)\n const message = url.searchParams.get('flash')\n const type = normalizeFlashKind(url.searchParams.get('type'))\n if (message) {\n // Always strip the params so a spoofed link does not linger in history,\n // but only render the banner for same-origin navigations.\n const trusted = isSameOriginFlashNavigation()\n url.searchParams.delete('flash')\n url.searchParams.delete('type')\n window.history.replaceState({}, '', url.toString())\n if (trusted) {\n showFlash(message, type)\n }\n }\n }, [locationKey, showFlash])\n\n // Listen for programmatic flash events\n React.useEffect(() => {\n const handler = (e: Event) => {\n const ce = e as CustomEvent<{ message?: string; type?: FlashKind }>\n const text = ce.detail?.message\n const t = ce.detail?.type || 'info'\n if (!text) return\n showFlash(text, t)\n }\n window.addEventListener('flash', handler as EventListener)\n return () => window.removeEventListener('flash', handler as EventListener)\n }, [showFlash])\n\n const handleDismiss = React.useCallback(() => {\n clearDismissTimer()\n setMsg(null)\n }, [clearDismissTimer])\n\n if (!msg || !isPrimaryHost) return null\n\n return (\n <div className=\"pointer-events-none fixed left-3 right-3 top-3 z-toast sm:left-auto sm:right-4 sm:w-[380px]\">\n <div className=\"pointer-events-auto\">\n <Alert\n status={flashKindToAlertStatus[kind]}\n size=\"sm\"\n dismissible\n onDismiss={handleDismiss}\n className=\"shadow-md\"\n >\n {msg}\n </Alert>\n </div>\n </div>\n )\n}\n\nexport function FlashMessages() {\n return (\n <React.Suspense fallback={null}>\n <FlashMessagesInner />\n </React.Suspense>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { Alert, type AlertStatus } from '../primitives/alert'\n\nexport type FlashKind = 'success' | 'error' | 'warning' | 'info'\n\nconst flashKindToAlertStatus: Record<FlashKind, AlertStatus> = {\n success: 'success',\n error: 'error',\n warning: 'warning',\n info: 'information',\n}\n\nfunction normalizeFlashKind(value: string | null | undefined): FlashKind {\n if (value && Object.prototype.hasOwnProperty.call(flashKindToAlertStatus, value)) {\n return value as FlashKind\n }\n return 'success'\n}\n\n// A URL-supplied flash is only trustworthy when the navigation that carried it\n// originated from the same origin (an in-app POST->GET redirect or client-side\n// navigation). Cross-origin links \u2014 the phishing/content-spoofing vector \u2014 must\n// not be allowed to render attacker-controlled copy inside an authoritative banner.\nfunction isSameOriginFlashNavigation(): boolean {\n if (typeof window === 'undefined') return false\n const referrer = document.referrer\n // No referrer (direct load, bookmark, or referrer stripped) is ambiguous, not a\n // cross-origin redirect we can attribute to an attacker page; allow it.\n if (!referrer) return true\n try {\n return new URL(referrer).origin === window.location.origin\n } catch {\n return false\n }\n}\n\n// A page that flashes from a mount effect (e.g. reading an OAuth callback's\n// result params) can run before any `<FlashMessages />` host has attached its\n// listener: on a full page load hydration is split across Suspense boundaries,\n// so the page's effects may commit ahead of the hosts' and the event would be\n// dropped. Keep the latest undelivered message briefly and let the hosts that\n// start listening in the same commit show it, so the primary-host election\n// stays in sync.\nconst PENDING_FLASH_MAX_AGE_MS = 5000\nlet listeningFlashHostCount = 0\nlet pendingFlash: { message: string; type: FlashKind; queuedAt: number } | null = null\n\nfunction readPendingFlash(): { message: string; type: FlashKind } | null {\n const pending = pendingFlash\n if (!pending) return null\n if (Date.now() - pending.queuedAt > PENDING_FLASH_MAX_AGE_MS) {\n pendingFlash = null\n return null\n }\n setTimeout(() => {\n if (pendingFlash === pending) pendingFlash = null\n }, 0)\n return pending\n}\n\n// Programmatic API to show a flash message without navigation.\n// Consumers can import { flash } and call flash('text', 'error').\nexport function flash(message: string, type: FlashKind = 'info') {\n if (typeof window === 'undefined') return\n if (listeningFlashHostCount === 0) {\n pendingFlash = { message, type, queuedAt: Date.now() }\n }\n const evt = new CustomEvent('flash', { detail: { message, type } })\n window.dispatchEvent(evt)\n}\n\n// Two hosts mount `<FlashMessages />` over the same page: `FrontendLayout`\n// wraps the entire app (backend routes included, via `AppProviders`) and\n// `AppShell` mounts its own copy inside `<main>`. Both listen to the same\n// `flash` event, so every backend toast rendered twice \u2014 two stacked fixed\n// overlays, each with its own dismiss button, so dismissing one left the other\n// behind. Both mount points are public surfaces third-party apps rely on, so\n// instead of deleting one, only the first host to mount renders; the others\n// keep their state in sync and take over if it unmounts.\nconst mountedFlashHostIds: symbol[] = []\nconst flashHostListeners = new Set<() => void>()\n\nfunction notifyFlashHostListeners() {\n for (const listener of flashHostListeners) listener()\n}\n\nfunction subscribeFlashHosts(listener: () => void): () => void {\n flashHostListeners.add(listener)\n return () => {\n flashHostListeners.delete(listener)\n }\n}\n\nfunction getPrimaryFlashHostId(): symbol | null {\n return mountedFlashHostIds[0] ?? null\n}\n\nfunction getServerFlashHostId(): symbol | null {\n return null\n}\n\nfunction useIsPrimaryFlashHost(): boolean {\n const hostIdRef = React.useRef<symbol | null>(null)\n if (hostIdRef.current === null) hostIdRef.current = Symbol('om-flash-host')\n const hostId = hostIdRef.current\n\n React.useEffect(() => {\n mountedFlashHostIds.push(hostId)\n notifyFlashHostListeners()\n return () => {\n const index = mountedFlashHostIds.indexOf(hostId)\n if (index >= 0) mountedFlashHostIds.splice(index, 1)\n notifyFlashHostListeners()\n }\n }, [hostId])\n\n return React.useSyncExternalStore(subscribeFlashHosts, getPrimaryFlashHostId, getServerFlashHostId) === hostId\n}\n\ntype HistoryMethod = History['pushState']\n\nfunction useLocationKey() {\n const [locationKey, setLocationKey] = React.useState(() => {\n if (typeof window === 'undefined') return ''\n return window.location.href\n })\n const locationKeyRef = React.useRef(locationKey)\n\n React.useEffect(() => {\n locationKeyRef.current = locationKey\n }, [locationKey])\n\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n\n let active = true\n const scheduleUpdate = (href: string) => {\n const run = () => {\n if (!active) return\n if (locationKeyRef.current === href) return\n locationKeyRef.current = href\n setLocationKey(href)\n }\n if (typeof queueMicrotask === 'function') {\n queueMicrotask(run)\n } else {\n setTimeout(run, 0)\n }\n }\n const updateLocation = () => {\n if (!active) return\n const href = window.location.href\n if (href === locationKeyRef.current) return\n scheduleUpdate(href)\n }\n\n const deferredUpdateLocation = () => {\n setTimeout(updateLocation, 0)\n }\n\n const originalPush: HistoryMethod = window.history.pushState.bind(window.history)\n const originalReplace: HistoryMethod = window.history.replaceState.bind(window.history)\n\n const pushState: HistoryMethod = (...args) => {\n originalPush(...args)\n deferredUpdateLocation()\n }\n\n const replaceState: HistoryMethod = (...args) => {\n originalReplace(...args)\n deferredUpdateLocation()\n }\n\n window.history.pushState = pushState\n window.history.replaceState = replaceState\n window.addEventListener('popstate', updateLocation)\n window.addEventListener('hashchange', updateLocation)\n updateLocation()\n\n return () => {\n active = false\n window.history.pushState = originalPush\n window.history.replaceState = originalReplace\n window.removeEventListener('popstate', updateLocation)\n window.removeEventListener('hashchange', updateLocation)\n }\n }, [])\n\n return locationKey\n}\n\nfunction FlashMessagesInner() {\n const [msg, setMsg] = React.useState<string | null>(null)\n const [kind, setKind] = React.useState<FlashKind>('info')\n const isPrimaryHost = useIsPrimaryFlashHost()\n const locationKey = useLocationKey()\n const dismissTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n\n const clearDismissTimer = React.useCallback(() => {\n if (dismissTimerRef.current) {\n clearTimeout(dismissTimerRef.current)\n dismissTimerRef.current = null\n }\n }, [])\n\n const showFlash = React.useCallback((message: string, type: FlashKind) => {\n clearDismissTimer()\n setMsg(message)\n setKind(type)\n dismissTimerRef.current = setTimeout(() => {\n dismissTimerRef.current = null\n setMsg(null)\n }, 3000)\n }, [clearDismissTimer])\n\n React.useEffect(() => {\n return () => {\n clearDismissTimer()\n }\n }, [clearDismissTimer])\n\n // Read flash from URL on any navigation change (client-side too)\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n const url = new URL(window.location.href)\n const message = url.searchParams.get('flash')\n const type = normalizeFlashKind(url.searchParams.get('type'))\n if (message) {\n // Always strip the params so a spoofed link does not linger in history,\n // but only render the banner for same-origin navigations.\n const trusted = isSameOriginFlashNavigation()\n url.searchParams.delete('flash')\n url.searchParams.delete('type')\n window.history.replaceState({}, '', url.toString())\n if (trusted) {\n showFlash(message, type)\n }\n }\n }, [locationKey, showFlash])\n\n // Listen for programmatic flash events\n React.useEffect(() => {\n const handler = (e: Event) => {\n const ce = e as CustomEvent<{ message?: string; type?: FlashKind }>\n const text = ce.detail?.message\n const t = ce.detail?.type || 'info'\n if (!text) return\n showFlash(text, t)\n }\n window.addEventListener('flash', handler as EventListener)\n listeningFlashHostCount += 1\n const pending = readPendingFlash()\n if (pending) showFlash(pending.message, pending.type)\n return () => {\n listeningFlashHostCount -= 1\n window.removeEventListener('flash', handler as EventListener)\n }\n }, [showFlash])\n\n const handleDismiss = React.useCallback(() => {\n clearDismissTimer()\n setMsg(null)\n }, [clearDismissTimer])\n\n if (!msg || !isPrimaryHost) return null\n\n return (\n <div className=\"pointer-events-none fixed left-3 right-3 top-3 z-toast sm:left-auto sm:right-4 sm:w-[380px]\">\n <div className=\"pointer-events-auto\">\n <Alert\n status={flashKindToAlertStatus[kind]}\n size=\"sm\"\n dismissible\n onDismiss={handleDismiss}\n className=\"shadow-md\"\n >\n {msg}\n </Alert>\n </div>\n </div>\n )\n}\n\nexport function FlashMessages() {\n return (\n <React.Suspense fallback={null}>\n <FlashMessagesInner />\n </React.Suspense>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7464.1.9a392fca41",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
158
|
+
"@open-mercato/shared": "0.9.1-develop.7464.1.9a392fca41",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
165
|
+
"@open-mercato/shared": "0.9.1-develop.7464.1.9a392fca41",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|
|
@@ -35,10 +35,37 @@ function isSameOriginFlashNavigation(): boolean {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// A page that flashes from a mount effect (e.g. reading an OAuth callback's
|
|
39
|
+
// result params) can run before any `<FlashMessages />` host has attached its
|
|
40
|
+
// listener: on a full page load hydration is split across Suspense boundaries,
|
|
41
|
+
// so the page's effects may commit ahead of the hosts' and the event would be
|
|
42
|
+
// dropped. Keep the latest undelivered message briefly and let the hosts that
|
|
43
|
+
// start listening in the same commit show it, so the primary-host election
|
|
44
|
+
// stays in sync.
|
|
45
|
+
const PENDING_FLASH_MAX_AGE_MS = 5000
|
|
46
|
+
let listeningFlashHostCount = 0
|
|
47
|
+
let pendingFlash: { message: string; type: FlashKind; queuedAt: number } | null = null
|
|
48
|
+
|
|
49
|
+
function readPendingFlash(): { message: string; type: FlashKind } | null {
|
|
50
|
+
const pending = pendingFlash
|
|
51
|
+
if (!pending) return null
|
|
52
|
+
if (Date.now() - pending.queuedAt > PENDING_FLASH_MAX_AGE_MS) {
|
|
53
|
+
pendingFlash = null
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
if (pendingFlash === pending) pendingFlash = null
|
|
58
|
+
}, 0)
|
|
59
|
+
return pending
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
// Programmatic API to show a flash message without navigation.
|
|
39
63
|
// Consumers can import { flash } and call flash('text', 'error').
|
|
40
64
|
export function flash(message: string, type: FlashKind = 'info') {
|
|
41
65
|
if (typeof window === 'undefined') return
|
|
66
|
+
if (listeningFlashHostCount === 0) {
|
|
67
|
+
pendingFlash = { message, type, queuedAt: Date.now() }
|
|
68
|
+
}
|
|
42
69
|
const evt = new CustomEvent('flash', { detail: { message, type } })
|
|
43
70
|
window.dispatchEvent(evt)
|
|
44
71
|
}
|
|
@@ -222,7 +249,13 @@ function FlashMessagesInner() {
|
|
|
222
249
|
showFlash(text, t)
|
|
223
250
|
}
|
|
224
251
|
window.addEventListener('flash', handler as EventListener)
|
|
225
|
-
|
|
252
|
+
listeningFlashHostCount += 1
|
|
253
|
+
const pending = readPendingFlash()
|
|
254
|
+
if (pending) showFlash(pending.message, pending.type)
|
|
255
|
+
return () => {
|
|
256
|
+
listeningFlashHostCount -= 1
|
|
257
|
+
window.removeEventListener('flash', handler as EventListener)
|
|
258
|
+
}
|
|
226
259
|
}, [showFlash])
|
|
227
260
|
|
|
228
261
|
const handleDismiss = React.useCallback(() => {
|
|
@@ -125,6 +125,71 @@ describe('FlashMessages', () => {
|
|
|
125
125
|
expect(screen.getAllByText('Project team updated.')).toHaveLength(1)
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Regression for #6402: on a full page load a page's mount effect can commit
|
|
130
|
+
* before any host attaches its listener, so the event used to be dropped and
|
|
131
|
+
* the user saw nothing. Rendering the flasher first reproduces that ordering.
|
|
132
|
+
*/
|
|
133
|
+
it('shows a flash dispatched before any host was listening once a host mounts', () => {
|
|
134
|
+
function FlashesOnMount() {
|
|
135
|
+
React.useEffect(() => {
|
|
136
|
+
flash('Channel connected (gmail).', 'success')
|
|
137
|
+
}, [])
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
renderWithProviders(
|
|
142
|
+
<>
|
|
143
|
+
<FlashesOnMount />
|
|
144
|
+
<FlashMessages />
|
|
145
|
+
</>,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
expect(screen.getByText('Channel connected (gmail).')).toBeInTheDocument()
|
|
149
|
+
const badge = document.querySelector('[data-slot="alert-icon-badge"]')
|
|
150
|
+
expect(badge?.getAttribute('data-status')).toBe('success')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('renders an early flash once when several hosts mount in the same commit', () => {
|
|
154
|
+
flash('This connection link was already used.', 'error')
|
|
155
|
+
|
|
156
|
+
renderWithProviders(
|
|
157
|
+
<>
|
|
158
|
+
<FlashMessages />
|
|
159
|
+
<FlashMessages />
|
|
160
|
+
</>,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
expect(screen.getAllByText('This connection link was already used.')).toHaveLength(1)
|
|
164
|
+
const badge = document.querySelector('[data-slot="alert-icon-badge"]')
|
|
165
|
+
expect(badge?.getAttribute('data-status')).toBe('error')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('does not replay an early flash to a host that mounts later', () => {
|
|
169
|
+
flash('Already delivered.', 'success')
|
|
170
|
+
const first = renderWithProviders(<FlashMessages />)
|
|
171
|
+
expect(screen.getByText('Already delivered.')).toBeInTheDocument()
|
|
172
|
+
first.unmount()
|
|
173
|
+
|
|
174
|
+
act(() => {
|
|
175
|
+
jest.advanceTimersByTime(0)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
renderWithProviders(<FlashMessages />)
|
|
179
|
+
expect(screen.queryByText('Already delivered.')).not.toBeInTheDocument()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('drops an undelivered flash that is older than the pending window', () => {
|
|
183
|
+
flash('Too old to show.', 'success')
|
|
184
|
+
|
|
185
|
+
act(() => {
|
|
186
|
+
jest.advanceTimersByTime(5001)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
renderWithProviders(<FlashMessages />)
|
|
190
|
+
expect(screen.queryByText('Too old to show.')).not.toBeInTheDocument()
|
|
191
|
+
})
|
|
192
|
+
|
|
128
193
|
it('auto-dismisses programmatic flashes', () => {
|
|
129
194
|
renderWithProviders(<FlashMessages />)
|
|
130
195
|
|