@djangocfg/layouts 2.1.607 → 2.1.609
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.
- package/README.md +4 -0
- package/package.json +12 -12
- package/src/components/account/AccountInline.tsx +122 -0
- package/src/components/account/AccountMenu.test.tsx +110 -0
- package/src/components/account/AccountMenu.tsx +117 -0
- package/src/components/account/accountContext.ts +14 -0
- package/src/components/account/index.ts +13 -0
- package/src/components/account/triggers.tsx +100 -0
- package/src/{layouts/SettingsLayout/hooks → components/account}/useAccountMenuItems.tsx +45 -17
- package/src/components/account/useAccountModel.tsx +76 -0
- package/src/components/errors/AppErrorFallback.test.tsx +33 -0
- package/src/components/errors/AppErrorFallback.tsx +42 -0
- package/src/components/errors/ErrorBoundary.tsx +4 -41
- package/src/components/errors/ErrorLayout.tsx +17 -1
- package/src/components/errors/README.md +6 -1
- package/src/components/errors/chunkReload.test.ts +89 -0
- package/src/components/errors/index.ts +2 -0
- package/src/layouts/AppLayout/BaseApp.tsx +15 -55
- package/src/layouts/AppLayout/index.ts +1 -0
- package/src/layouts/AppLayout/pwaMeta.test.ts +36 -0
- package/src/layouts/AppLayout/pwaMeta.tsx +40 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +32 -1
- package/src/layouts/PrivateLayout/README.md +22 -6
- package/src/layouts/PrivateLayout/brand.test.tsx +208 -0
- package/src/layouts/PrivateLayout/components/ForbiddenState.tsx +1 -1
- package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +176 -11
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +85 -25
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +4 -2
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +16 -123
- package/src/layouts/PrivateLayout/components/PrivateSidebarTrigger.tsx +3 -3
- package/src/layouts/PrivateLayout/components/PrivateTopBarAccount.tsx +27 -0
- package/src/layouts/PrivateLayout/components/SidebarBody.test.tsx +73 -0
- package/src/layouts/PrivateLayout/components/SidebarBody.tsx +13 -4
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +25 -14
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +10 -5
- package/src/layouts/PrivateLayout/components/SidebarPeek.tsx +1 -1
- package/src/layouts/PrivateLayout/context.tsx +15 -3
- package/src/layouts/PrivateLayout/geometry.test.ts +19 -7
- package/src/layouts/PrivateLayout/geometry.ts +30 -10
- package/src/layouts/PrivateLayout/hooks/useLandingRoute.ts +16 -0
- package/src/layouts/PrivateLayout/hooks/useLayoutVisual.ts +4 -4
- package/src/layouts/PrivateLayout/hooks/useSidebarPeek.tsx +1 -1
- package/src/layouts/PrivateLayout/index.ts +2 -0
- package/src/layouts/PrivateLayout/topBar.test.tsx +213 -0
- package/src/layouts/PrivateLayout/types.ts +27 -2
- package/src/layouts/PublicLayout/components/UserMenu.tsx +24 -297
- package/src/layouts/PublicLayout/components/index.ts +2 -2
- package/src/layouts/PublicLayout/hooks/useNavbarScroll.ts +13 -7
- package/src/layouts/SettingsLayout/components/AccountMenuButton.tsx +19 -60
- package/src/layouts/SettingsLayout/hooks/index.ts +2 -2
- package/src/layouts/types/layout.types.ts +7 -0
- package/src/shared/ProjectContext.tsx +18 -0
- package/src/testing/MockAuthProvider.tsx +5 -2
- /package/src/{layouts/PublicLayout/components → components/account}/UserAvatar.tsx +0 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the account menu shows, shared by every place it can live (sidebar footer, top bar avatar): the user, the
|
|
3
|
+
* display fields and the menu items. One source, so the menus can never drift apart.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React from 'react';
|
|
9
|
+
|
|
10
|
+
import { useAuth } from '@djangocfg/api/auth';
|
|
11
|
+
import type { MenuItem } from '@djangocfg/ui-core/components';
|
|
12
|
+
|
|
13
|
+
import type { UserMenuGroup, UserMenuLocaleConfig } from '../../layouts/types';
|
|
14
|
+
import { useAccountMenuItems } from './useAccountMenuItems';
|
|
15
|
+
|
|
16
|
+
export interface AccountView {
|
|
17
|
+
source: 'user' | 'dev-fallback';
|
|
18
|
+
displayName: string;
|
|
19
|
+
email: string | null;
|
|
20
|
+
avatarUrl: string;
|
|
21
|
+
plan: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseAccountModelOptions {
|
|
25
|
+
/** App link groups. */
|
|
26
|
+
groups?: UserMenuGroup[];
|
|
27
|
+
/** Plan shown after the name in the footer trigger. */
|
|
28
|
+
plan?: string | null;
|
|
29
|
+
i18n?: UserMenuLocaleConfig;
|
|
30
|
+
/** Extra items between the groups and Sign out (language, app links). */
|
|
31
|
+
extra?: MenuItem[];
|
|
32
|
+
/** Settings rows in the menu. @default true */
|
|
33
|
+
settings?: boolean;
|
|
34
|
+
/** Identity block above the items: the `email` (default) or a name + email `card`. */
|
|
35
|
+
identity?: 'email' | 'card';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useAccountModel({ groups, plan = null, i18n, settings = true, extra, identity = 'email' }: UseAccountModelOptions = {}) {
|
|
39
|
+
const { user } = useAuth();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* When `useAuth` returns a real user we use it; otherwise (dev only) a placeholder keeps the menu reachable for
|
|
43
|
+
* debugging. Production stays strict: callers render nothing without a user.
|
|
44
|
+
*/
|
|
45
|
+
const account = React.useMemo<AccountView>(() => {
|
|
46
|
+
if (user) {
|
|
47
|
+
return {
|
|
48
|
+
source: 'user',
|
|
49
|
+
displayName: user.display_username || user.email || 'User',
|
|
50
|
+
email: user.email ?? null,
|
|
51
|
+
avatarUrl: user.avatar ?? '',
|
|
52
|
+
plan,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
source: 'dev-fallback',
|
|
57
|
+
displayName: 'Guest (dev)',
|
|
58
|
+
email: null,
|
|
59
|
+
avatarUrl: '',
|
|
60
|
+
plan: plan ?? 'No session',
|
|
61
|
+
};
|
|
62
|
+
}, [user, plan]);
|
|
63
|
+
|
|
64
|
+
const headerLabel: React.ReactNode =
|
|
65
|
+
identity === 'card' ? (
|
|
66
|
+
<div className="flex flex-col space-y-1">
|
|
67
|
+
<p className="text-sm font-medium leading-none">{account.displayName}</p>
|
|
68
|
+
<p className="text-xs leading-none text-muted-foreground">{account.email}</p>
|
|
69
|
+
</div>
|
|
70
|
+
) : (
|
|
71
|
+
account.email ?? (account.source === 'dev-fallback' ? 'No active session' : null)
|
|
72
|
+
);
|
|
73
|
+
const menuItems = useAccountMenuItems({ groups, label: headerLabel, i18n, settings, extra });
|
|
74
|
+
|
|
75
|
+
return { user, account, menuItems };
|
|
76
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React, { act } from 'react';
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { AppErrorFallback } from './AppErrorFallback';
|
|
7
|
+
|
|
8
|
+
vi.mock('@djangocfg/i18n', () => ({
|
|
9
|
+
getT: (key: string) => key,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
|
+
|
|
14
|
+
function render(props = {}) {
|
|
15
|
+
const host = document.createElement('div');
|
|
16
|
+
act(() => createRoot(host).render(<AppErrorFallback {...props} />));
|
|
17
|
+
return host;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('AppErrorFallback', () => {
|
|
21
|
+
it('renders exactly one action button', () => {
|
|
22
|
+
const host = render();
|
|
23
|
+
const buttons = host.querySelectorAll('button');
|
|
24
|
+
expect(buttons).toHaveLength(1);
|
|
25
|
+
expect(buttons[0]!.textContent).toBe('layouts.errors.refreshPage');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('keeps one button when a support email is shown', () => {
|
|
29
|
+
const host = render({ supportEmail: 'help@example.com' });
|
|
30
|
+
expect(host.querySelectorAll('button')).toHaveLength(1);
|
|
31
|
+
expect(host.querySelector('a[href="mailto:help@example.com"]')).not.toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppErrorFallback — the one localized crash screen for Next app layouts.
|
|
3
|
+
*
|
|
4
|
+
* Exactly ONE action: "Refresh Page" (hard reload). A soft `reset()` re-mounts
|
|
5
|
+
* the same broken tree and usually crashes again, so it is not offered as a
|
|
6
|
+
* second near-identical button. Used by `BaseApp` (via UiProviders) and
|
|
7
|
+
* `ErrorBoundary`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use client';
|
|
11
|
+
|
|
12
|
+
import { getT } from '@djangocfg/i18n';
|
|
13
|
+
|
|
14
|
+
export interface AppErrorFallbackProps {
|
|
15
|
+
supportEmail?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function AppErrorFallback({ supportEmail }: AppErrorFallbackProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
|
21
|
+
<div className="max-w-md w-full space-y-4 text-center">
|
|
22
|
+
<h1 className="text-2xl font-bold text-foreground">{getT('layouts.errors.somethingWentWrong')}</h1>
|
|
23
|
+
<p className="text-muted-foreground">{getT('layouts.errors.tryRefreshing')}</p>
|
|
24
|
+
{supportEmail && (
|
|
25
|
+
<p className="text-sm text-muted-foreground">
|
|
26
|
+
{getT('layouts.errors.persistsContact', { email: '' })}{' '}
|
|
27
|
+
<a href={`mailto:${supportEmail}`} className="text-primary hover:underline">
|
|
28
|
+
{supportEmail}
|
|
29
|
+
</a>
|
|
30
|
+
</p>
|
|
31
|
+
)}
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={() => window.location.reload()}
|
|
35
|
+
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
|
36
|
+
>
|
|
37
|
+
{getT('layouts.errors.refreshPage')}
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { useMemo, type ComponentType, type ErrorInfo, type ReactNode } from 'react';
|
|
14
14
|
|
|
15
15
|
import { Boundary, type BoundaryRenderProps } from '@djangocfg/ui-core/components';
|
|
16
|
-
import {
|
|
16
|
+
import { AppErrorFallback } from './AppErrorFallback';
|
|
17
17
|
|
|
18
18
|
interface ErrorBoundaryProps {
|
|
19
19
|
children: ReactNode;
|
|
@@ -21,50 +21,13 @@ interface ErrorBoundaryProps {
|
|
|
21
21
|
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/** Localized fullscreen fallback. `reset()` re-mounts the tree (ui-core); we
|
|
25
|
-
* also offer a hard reload, matching the prior behavior. */
|
|
26
|
-
function makeFallback(supportEmail?: string) {
|
|
27
|
-
return function ErrorFallback({ reset }: BoundaryRenderProps) {
|
|
28
|
-
const title = getT('layouts.errors.somethingWentWrong');
|
|
29
|
-
const description = getT('layouts.errors.tryRefreshing');
|
|
30
|
-
const refreshButton = getT('layouts.errors.refreshPage');
|
|
31
|
-
return (
|
|
32
|
-
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
|
33
|
-
<div className="max-w-md w-full space-y-4 text-center">
|
|
34
|
-
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
|
35
|
-
<p className="text-muted-foreground">{description}</p>
|
|
36
|
-
{supportEmail && (
|
|
37
|
-
<p className="text-sm text-muted-foreground">
|
|
38
|
-
{getT('layouts.errors.persistsContact', { email: '' })}{' '}
|
|
39
|
-
<a
|
|
40
|
-
href={`mailto:${supportEmail}`}
|
|
41
|
-
className="text-primary hover:underline"
|
|
42
|
-
>
|
|
43
|
-
{supportEmail}
|
|
44
|
-
</a>
|
|
45
|
-
</p>
|
|
46
|
-
)}
|
|
47
|
-
<button
|
|
48
|
-
onClick={() => {
|
|
49
|
-
reset();
|
|
50
|
-
// Hard reload as a fallback if a pure reset can't recover.
|
|
51
|
-
window.location.reload();
|
|
52
|
-
}}
|
|
53
|
-
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
|
54
|
-
>
|
|
55
|
-
{refreshButton}
|
|
56
|
-
</button>
|
|
57
|
-
</div>
|
|
58
|
-
</div>
|
|
59
|
-
);
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
24
|
export function ErrorBoundary({ children, supportEmail, onError }: ErrorBoundaryProps) {
|
|
64
25
|
// Memoize so the boundary's FallbackComponent identity is stable across
|
|
65
26
|
// renders (no remount churn).
|
|
66
27
|
const Fallback = useMemo<ComponentType<BoundaryRenderProps>>(
|
|
67
|
-
() =>
|
|
28
|
+
() => function ErrorFallback() {
|
|
29
|
+
return <AppErrorFallback supportEmail={supportEmail} />;
|
|
30
|
+
},
|
|
68
31
|
[supportEmail],
|
|
69
32
|
);
|
|
70
33
|
return (
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
|
|
18
18
|
'use client';
|
|
19
19
|
|
|
20
|
-
import React, { useMemo } from 'react';
|
|
20
|
+
import React, { useEffect, useMemo } from 'react';
|
|
21
21
|
|
|
22
22
|
import { Button } from '@djangocfg/ui-core/components';
|
|
23
|
+
import { canAutoReloadForChunk, isChunkLoadError, reloadForChunkError } from '@djangocfg/ui-core/lib';
|
|
23
24
|
import { useAppT } from '@djangocfg/i18n';
|
|
24
25
|
|
|
25
26
|
import { getErrorContent } from './errorConfig';
|
|
@@ -54,6 +55,14 @@ export interface ErrorLayoutProps {
|
|
|
54
55
|
* it finally matters.
|
|
55
56
|
*/
|
|
56
57
|
statusUrl?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The caught error (`global-error.tsx` / `error.tsx` receive it as `error`).
|
|
60
|
+
* When it is a stale-chunk failure (ChunkLoadError, cancelled `/_next/static`
|
|
61
|
+
* fetch after a deploy) the page reloads ONCE (sessionStorage loop guard) and
|
|
62
|
+
* renders nothing meanwhile; if the reload already happened, the normal error
|
|
63
|
+
* screen is shown.
|
|
64
|
+
*/
|
|
65
|
+
error?: unknown;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
/**
|
|
@@ -82,8 +91,13 @@ export function ErrorLayout({
|
|
|
82
91
|
illustration,
|
|
83
92
|
supportEmail = 'support@example.com',
|
|
84
93
|
statusUrl,
|
|
94
|
+
error,
|
|
85
95
|
}: ErrorLayoutProps) {
|
|
86
96
|
const t = useAppT();
|
|
97
|
+
const staleChunk = error !== undefined && isChunkLoadError(error);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (staleChunk) reloadForChunkError();
|
|
100
|
+
}, [staleChunk]);
|
|
87
101
|
|
|
88
102
|
const labels = useMemo(() => ({
|
|
89
103
|
goBack: t('layouts.errors.goBack'),
|
|
@@ -121,6 +135,8 @@ export function ErrorLayout({
|
|
|
121
135
|
window.location.href = '/';
|
|
122
136
|
};
|
|
123
137
|
|
|
138
|
+
if (staleChunk && canAutoReloadForChunk()) return null;
|
|
139
|
+
|
|
124
140
|
return (
|
|
125
141
|
<div className="min-h-screen flex items-center justify-center px-4 bg-background">
|
|
126
142
|
<div className="max-w-2xl w-full text-center space-y-8">
|
|
@@ -11,11 +11,16 @@ All boundaries here build on the single `Boundary` primitive in
|
|
|
11
11
|
|
|
12
12
|
| File | What it does |
|
|
13
13
|
|------|--------------|
|
|
14
|
-
| `
|
|
14
|
+
| `AppErrorFallback.tsx` | The one localized crash screen: title, text, support email, ONE "Refresh Page" button (hard reload). Used by `BaseApp` and `ErrorBoundary`. |
|
|
15
|
+
| `ErrorBoundary.tsx` | Thin wrapper over ui-core `Boundary` — renders `AppErrorFallback` (for direct use; `BaseApp` no longer wraps it). |
|
|
15
16
|
| `MonitorBoundary.tsx` | ui-core `Boundary` that also reports to `@djangocfg/devtools`. |
|
|
16
17
|
| `ErrorLayout.tsx` | Full-page error layout (`getErrorContent` / `ERROR_CODES`). |
|
|
17
18
|
| `ErrorsTracker/` | Global runtime tracker — listens for error CustomEvents and shows toasts. |
|
|
18
19
|
|
|
20
|
+
## Stale chunks after a deploy
|
|
21
|
+
|
|
22
|
+
A chunk-load failure (ChunkLoadError, "Loading chunk N failed", dynamic import failure, cancelled fetch of `/_next/static/...`) is a stale tab, not a crash: the page reloads ONCE instead of showing the error screen. Logic lives in `@djangocfg/ui-core/lib` (`chunk-reload.ts`), wired in `Boundary` (render errors), `UiProviders` (window `error` + `unhandledrejection`) and `ErrorLayout error={error}` (`global-error.tsx`). Loop guard: `sessionStorage` key `djangocfg:chunk-reload-at`, 30 s window; with storage blocked no auto reload. Tests: `chunkReload.test.ts`, `AppErrorFallback.test.tsx`.
|
|
23
|
+
|
|
19
24
|
## ErrorLayout — `statusUrl`
|
|
20
25
|
|
|
21
26
|
`ErrorLayout` takes an optional `statusUrl`, rendered as a secondary link beside
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CHUNK_RELOAD_STORAGE_KEY,
|
|
6
|
+
CHUNK_RELOAD_WINDOW_MS,
|
|
7
|
+
canAutoReloadForChunk,
|
|
8
|
+
installChunkReloadListeners,
|
|
9
|
+
isChunkLoadError,
|
|
10
|
+
isStaticAssetLoadEvent,
|
|
11
|
+
reloadForChunkError,
|
|
12
|
+
} from '@djangocfg/ui-core/lib';
|
|
13
|
+
|
|
14
|
+
describe('isChunkLoadError', () => {
|
|
15
|
+
it.each([
|
|
16
|
+
['ChunkLoadError by name', Object.assign(new Error('x'), { name: 'ChunkLoadError' })],
|
|
17
|
+
['webpack JS chunk', new Error('Loading chunk 123 failed.\n(error: /_next/static/chunks/123.js)')],
|
|
18
|
+
['webpack CSS chunk', new Error('Loading CSS chunk 45 failed.')],
|
|
19
|
+
['Chrome dynamic import', new TypeError('Failed to fetch dynamically imported module: /_next/static/a.js')],
|
|
20
|
+
['Firefox dynamic import', new TypeError('error loading dynamically imported module')],
|
|
21
|
+
['Safari module script', new TypeError('Importing a module script failed.')],
|
|
22
|
+
['cancelled fetch of static asset', new TypeError('Failed to fetch /_next/static/chunks/app/page-abc.js')],
|
|
23
|
+
['aborted static asset via stack', Object.assign(new Error('signal is aborted'), { stack: 'at https://x.io/_next/static/chunks/1.js:1' })],
|
|
24
|
+
['string reason', 'Loading chunk 9 failed.'],
|
|
25
|
+
])('detects %s', (_n, err) => expect(isChunkLoadError(err)).toBe(true));
|
|
26
|
+
|
|
27
|
+
it.each([
|
|
28
|
+
['ordinary error', new Error('Cannot read properties of undefined')],
|
|
29
|
+
['API fetch failure', new TypeError('Failed to fetch https://api.example.com/items')],
|
|
30
|
+
['null', null],
|
|
31
|
+
['undefined', undefined],
|
|
32
|
+
])('ignores %s', (_n, err) => expect(isChunkLoadError(err)).toBe(false));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('isStaticAssetLoadEvent', () => {
|
|
36
|
+
it('matches failed script/link under /_next/static and nothing else', () => {
|
|
37
|
+
const script = document.createElement('script');
|
|
38
|
+
script.src = 'https://x.io/_next/static/chunks/1.js';
|
|
39
|
+
const other = document.createElement('script');
|
|
40
|
+
other.src = 'https://cdn.io/lib.js';
|
|
41
|
+
const link = document.createElement('link');
|
|
42
|
+
link.href = 'https://x.io/_next/static/css/a.css';
|
|
43
|
+
const mk = (target: Element) => Object.defineProperty(new Event('error'), 'target', { value: target });
|
|
44
|
+
expect(isStaticAssetLoadEvent(mk(script))).toBe(true);
|
|
45
|
+
expect(isStaticAssetLoadEvent(mk(link))).toBe(true);
|
|
46
|
+
expect(isStaticAssetLoadEvent(mk(other))).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('reload guard', () => {
|
|
51
|
+
const reload = vi.fn();
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
sessionStorage.clear();
|
|
54
|
+
reload.mockClear();
|
|
55
|
+
Object.defineProperty(window, 'location', { configurable: true, value: { ...window.location, reload } });
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => vi.restoreAllMocks());
|
|
58
|
+
|
|
59
|
+
it('reloads once, then blocks a second reload inside the window', () => {
|
|
60
|
+
expect(reloadForChunkError(1_000)).toBe(true);
|
|
61
|
+
expect(sessionStorage.getItem(CHUNK_RELOAD_STORAGE_KEY)).toBe('1000');
|
|
62
|
+
expect(reloadForChunkError(1_000 + 5_000)).toBe(false);
|
|
63
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('allows another reload after the window passes', () => {
|
|
67
|
+
reloadForChunkError(1_000);
|
|
68
|
+
expect(canAutoReloadForChunk(1_000 + CHUNK_RELOAD_WINDOW_MS + 1)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('never reloads when sessionStorage is unusable (no loop risk)', () => {
|
|
72
|
+
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
73
|
+
throw new Error('denied');
|
|
74
|
+
});
|
|
75
|
+
expect(canAutoReloadForChunk()).toBe(false);
|
|
76
|
+
expect(reloadForChunkError()).toBe(false);
|
|
77
|
+
expect(reload).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('window listeners reload once for an unhandled chunk rejection', () => {
|
|
81
|
+
const off = installChunkReloadListeners();
|
|
82
|
+
const ev = new Event('unhandledrejection') as Event & { reason?: unknown };
|
|
83
|
+
ev.reason = new Error('Loading chunk 7 failed.');
|
|
84
|
+
window.dispatchEvent(ev);
|
|
85
|
+
window.dispatchEvent(ev);
|
|
86
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
87
|
+
off();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export { ErrorBoundary } from './ErrorBoundary';
|
|
6
|
+
export { AppErrorFallback } from './AppErrorFallback';
|
|
7
|
+
export type { AppErrorFallbackProps } from './AppErrorFallback';
|
|
6
8
|
export { MonitorBoundary } from './MonitorBoundary';
|
|
7
9
|
export type { MonitorBoundaryProps } from './MonitorBoundary';
|
|
8
10
|
export { ErrorLayout } from './ErrorLayout';
|
|
@@ -54,65 +54,21 @@ import { useScrollToTop } from '@djangocfg/ui-core/hooks';
|
|
|
54
54
|
import { NextRouterAdapter, NextLinkProvider } from '@djangocfg/ui-core/adapters/nextjs';
|
|
55
55
|
import { NextIntlLinkBridge } from './NextIntlLinkBridge';
|
|
56
56
|
import { ThemeProvider } from '@djangocfg/ui-core/theme';
|
|
57
|
-
import
|
|
58
|
-
import { getT } from '@djangocfg/i18n';
|
|
57
|
+
import { AppErrorFallback } from '../../components/errors/AppErrorFallback';
|
|
59
58
|
import { ErrorTrackingProvider } from '../../components/errors/ErrorsTracker';
|
|
60
59
|
import { AnalyticsProvider } from '../../snippets/Analytics';
|
|
61
60
|
import { AuthDialog } from '../../snippets/AuthDialog';
|
|
62
61
|
|
|
63
62
|
import type { BaseLayoutProps } from '../types/layout.types';
|
|
64
63
|
import { LayoutI18nProvider } from '../../shared/LayoutI18nProvider';
|
|
64
|
+
import { ProjectNameProvider } from '../../shared/ProjectContext';
|
|
65
|
+
import { PwaMetaGuard } from './pwaMeta';
|
|
65
66
|
|
|
66
67
|
import React from 'react';
|
|
67
68
|
|
|
68
69
|
// For backwards compatibility, re-export as BaseAppProps
|
|
69
70
|
export type BaseAppProps = BaseLayoutProps;
|
|
70
71
|
|
|
71
|
-
/**
|
|
72
|
-
* Localized crash fallback fed into UiProviders' built-in Boundary. Renders the
|
|
73
|
-
* i18n'd "something went wrong" screen + optional support email. `reset()`
|
|
74
|
-
* re-mounts (soft recover); the Reload button hard-reloads when a soft reset
|
|
75
|
-
* can't recover (e.g. the crash is in a provider above the boundary's subtree).
|
|
76
|
-
*/
|
|
77
|
-
function AppErrorFallback({
|
|
78
|
-
reset,
|
|
79
|
-
supportEmail,
|
|
80
|
-
}: BoundaryRenderProps & { supportEmail?: string }) {
|
|
81
|
-
const title = getT('layouts.errors.somethingWentWrong');
|
|
82
|
-
const description = getT('layouts.errors.tryRefreshing');
|
|
83
|
-
const retry = getT('layouts.errors.refreshPage');
|
|
84
|
-
return (
|
|
85
|
-
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
|
86
|
-
<div className="max-w-md w-full space-y-4 text-center">
|
|
87
|
-
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
|
88
|
-
<p className="text-muted-foreground">{description}</p>
|
|
89
|
-
{supportEmail && (
|
|
90
|
-
<p className="text-sm text-muted-foreground">
|
|
91
|
-
{getT('layouts.errors.persistsContact', { email: '' })}{' '}
|
|
92
|
-
<a href={`mailto:${supportEmail}`} className="text-primary hover:underline">
|
|
93
|
-
{supportEmail}
|
|
94
|
-
</a>
|
|
95
|
-
</p>
|
|
96
|
-
)}
|
|
97
|
-
<div className="flex items-center justify-center gap-2">
|
|
98
|
-
<button
|
|
99
|
-
onClick={reset}
|
|
100
|
-
className="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
|
101
|
-
>
|
|
102
|
-
{retry}
|
|
103
|
-
</button>
|
|
104
|
-
<button
|
|
105
|
-
onClick={() => window.location.reload()}
|
|
106
|
-
className="rounded-md border border-border px-4 py-2 text-foreground hover:bg-muted"
|
|
107
|
-
>
|
|
108
|
-
{getT('layouts.errors.refreshPage')}
|
|
109
|
-
</button>
|
|
110
|
-
</div>
|
|
111
|
-
</div>
|
|
112
|
-
</div>
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
72
|
/**
|
|
117
73
|
* Lands every forward navigation at the top of the page.
|
|
118
74
|
*
|
|
@@ -150,6 +106,7 @@ export function BaseApp({
|
|
|
150
106
|
swr,
|
|
151
107
|
devtools: devtoolsConfig,
|
|
152
108
|
i18n,
|
|
109
|
+
pwaMeta = true,
|
|
153
110
|
}: BaseAppProps) {
|
|
154
111
|
// auth=false disables AuthProvider entirely (public apps with no login)
|
|
155
112
|
const authEnabled = auth !== false;
|
|
@@ -167,6 +124,8 @@ export function BaseApp({
|
|
|
167
124
|
};
|
|
168
125
|
|
|
169
126
|
const content = (
|
|
127
|
+
<>
|
|
128
|
+
{pwaMeta && <PwaMetaGuard />}
|
|
170
129
|
<ThemeProvider
|
|
171
130
|
defaultTheme={theme?.defaultTheme || 'system'}
|
|
172
131
|
storageKey={theme?.storageKey}
|
|
@@ -189,9 +148,7 @@ export function BaseApp({
|
|
|
189
148
|
onError={errorBoundary?.onError}
|
|
190
149
|
errorFallback={
|
|
191
150
|
enableErrorBoundary
|
|
192
|
-
? (
|
|
193
|
-
<AppErrorFallback {...props} supportEmail={errorBoundary?.supportEmail} />
|
|
194
|
-
)
|
|
151
|
+
? () => <AppErrorFallback supportEmail={errorBoundary?.supportEmail} />
|
|
195
152
|
: undefined
|
|
196
153
|
}
|
|
197
154
|
>
|
|
@@ -214,11 +171,13 @@ export function BaseApp({
|
|
|
214
171
|
<ScrollToTop />
|
|
215
172
|
<Devtools {...devtoolsProps} />
|
|
216
173
|
<LayoutI18nProvider value={i18n}>
|
|
217
|
-
{
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
174
|
+
<ProjectNameProvider value={project}>
|
|
175
|
+
{i18n?.routing ? (
|
|
176
|
+
<NextIntlLinkBridge routing={i18n.routing}>{children}</NextIntlLinkBridge>
|
|
177
|
+
) : (
|
|
178
|
+
children
|
|
179
|
+
)}
|
|
180
|
+
</ProjectNameProvider>
|
|
222
181
|
</LayoutI18nProvider>
|
|
223
182
|
<NextTopLoader
|
|
224
183
|
color="var(--primary)"
|
|
@@ -237,6 +196,7 @@ export function BaseApp({
|
|
|
237
196
|
</NextLinkProvider>
|
|
238
197
|
</NextRouterAdapter>
|
|
239
198
|
</ThemeProvider>
|
|
199
|
+
</>
|
|
240
200
|
);
|
|
241
201
|
|
|
242
202
|
// The error boundary is now UiProviders' built-in one (fed our i18n fallback
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { PWA_META_SCRIPT } from './pwaMeta';
|
|
5
|
+
|
|
6
|
+
const meta = (n: string) => document.head.querySelector(`meta[name="${n}"]`)?.getAttribute('content');
|
|
7
|
+
const run = () => new Function(PWA_META_SCRIPT)();
|
|
8
|
+
|
|
9
|
+
describe('PWA_META_SCRIPT', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
document.head.innerHTML = '';
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('adds viewport-fit=cover to an existing viewport and the iOS app tags', () => {
|
|
15
|
+
document.head.innerHTML = '<meta name="viewport" content="width=device-width, initial-scale=1">';
|
|
16
|
+
run();
|
|
17
|
+
expect(meta('viewport')).toBe('width=device-width, initial-scale=1, viewport-fit=cover');
|
|
18
|
+
expect(meta('apple-mobile-web-app-capable')).toBe('yes');
|
|
19
|
+
expect(meta('mobile-web-app-capable')).toBe('yes');
|
|
20
|
+
expect(meta('apple-mobile-web-app-status-bar-style')).toBe('black-translucent');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('creates a viewport when none exists', () => {
|
|
24
|
+
run();
|
|
25
|
+
expect(meta('viewport')).toContain('viewport-fit=cover');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('never overrides what the app set', () => {
|
|
29
|
+
document.head.innerHTML =
|
|
30
|
+
'<meta name="viewport" content="width=device-width, viewport-fit=contain">' +
|
|
31
|
+
'<meta name="apple-mobile-web-app-status-bar-style" content="default">';
|
|
32
|
+
run();
|
|
33
|
+
expect(meta('viewport')).toBe('width=device-width, viewport-fit=contain');
|
|
34
|
+
expect(meta('apple-mobile-web-app-status-bar-style')).toBe('default');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installed-app `<meta>` tags, applied by `BaseApp` so an app does not have to remember them.
|
|
3
|
+
*
|
|
4
|
+
* Next's App Router builds `<head>` from the server `viewport` / `metadata` exports; a client component such as
|
|
5
|
+
* `BaseApp` cannot contribute to them, and a second `<meta name="viewport">` would fight Next's own. So this is a tiny
|
|
6
|
+
* inline script (it runs while the HTML is parsed, before first paint) that completes what is missing in `<head>`:
|
|
7
|
+
*
|
|
8
|
+
* viewport + `viewport-fit=cover` (without it every `--safe-*` inset is 0 and the
|
|
9
|
+
* installed iOS app letterboxes)
|
|
10
|
+
* apple-mobile-web-app-capable yes (Next 16 only emits `mobile-web-app-capable`; iOS < 26 needs the apple tag)
|
|
11
|
+
* mobile-web-app-capable yes
|
|
12
|
+
* apple-mobile-web-app-status-bar-style black-translucent
|
|
13
|
+
*
|
|
14
|
+
* Existing tags are never overridden (an app's own `appleWebApp.statusBarStyle` wins); only `viewport-fit` is added to
|
|
15
|
+
* an existing viewport that lacks it. A MutationObserver re-applies it if a client navigation rewrites the viewport tag.
|
|
16
|
+
* Prefer exporting `viewport = createViewport(...)` (default `viewportFit: 'cover'`): that puts it in the server HTML;
|
|
17
|
+
* this is the safety net for apps that forgot.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as React from 'react';
|
|
21
|
+
|
|
22
|
+
export const PWA_META_SCRIPT = `(function(){try{
|
|
23
|
+
var d=document,h=d.head;if(!h)return;
|
|
24
|
+
function q(n){return h.querySelector('meta[name="'+n+'"]')}
|
|
25
|
+
function ensure(n,c){if(!q(n)){var m=d.createElement('meta');m.name=n;m.content=c;h.appendChild(m)}}
|
|
26
|
+
function fit(){var v=q('viewport');
|
|
27
|
+
if(!v){v=d.createElement('meta');v.name='viewport';v.content='width=device-width, initial-scale=1, viewport-fit=cover';h.appendChild(v);return}
|
|
28
|
+
var c=v.getAttribute('content')||'';
|
|
29
|
+
if(!/viewport-fit\\s*=/.test(c))v.setAttribute('content',(c?c+', ':'')+'viewport-fit=cover')}
|
|
30
|
+
fit();
|
|
31
|
+
ensure('apple-mobile-web-app-capable','yes');
|
|
32
|
+
ensure('mobile-web-app-capable','yes');
|
|
33
|
+
ensure('apple-mobile-web-app-status-bar-style','black-translucent');
|
|
34
|
+
if(window.MutationObserver){new MutationObserver(function(){var v=q('viewport');if(!v||!/viewport-fit\\s*=\\s*cover/.test(v.getAttribute('content')||''))fit()}).observe(h,{subtree:true,childList:true,attributes:true,attributeFilter:['content']})}
|
|
35
|
+
}catch(e){}})();`;
|
|
36
|
+
|
|
37
|
+
/** Renders the inline script. Server-rendered into the HTML; React does not re-run it on hydration. */
|
|
38
|
+
export function PwaMetaGuard() {
|
|
39
|
+
return <script dangerouslySetInnerHTML={{ __html: PWA_META_SCRIPT }} suppressHydrationWarning />;
|
|
40
|
+
}
|