Skip to content

Commit cb7eaf6

Browse files
committed
fix(webapp): end a failed lazy chunk in an error card with Try again
A next/dynamic loader that renders only a skeleton ignores the error prop. When the device is offline, or chunk recovery gives up, the skeleton stays forever. ChunkLoadFallback is now the loading view for a lazy part. It shows the skeleton while the chunk loads. On a failure, it shows the EmptyState error card, and Try again calls the loader's retry, which brings the skeleton back. The six Settings sections and both QR codes use it. The Share card's Present trigger no longer wraps the code. It is laid over the code and hides while the error card shows, so Try again is never inside a button. The pad QR card's skeleton is now square, to match the loaded code. Refs #463 Refs #464
1 parent 03888a3 commit cb7eaf6

4 files changed

Lines changed: 63 additions & 17 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { EmptyState } from '@components/ui/EmptyState'
2+
import type { DynamicOptionsLoadingProps } from 'next/dynamic'
3+
import type { ReactNode } from 'react'
4+
5+
interface ChunkLoadFallbackProps extends DynamicOptionsLoadingProps {
6+
skeleton: ReactNode
7+
/** Goes to the error state only, for example to put it on a surface that differs from the panel. */
8+
className?: string
9+
}
10+
11+
/**
12+
* The `loading` view of a next/dynamic chunk. Offline, or after chunk recovery gives up, the
13+
* skeleton would stay forever. `retry` puts the chunk back in loading, so the skeleton returns.
14+
* The page-level `ChunkLoadError` in `pages/[...slugs].tsx` stays separate: it reloads the page.
15+
*/
16+
export function ChunkLoadFallback({ error, retry, skeleton, className }: ChunkLoadFallbackProps) {
17+
if (!error) return skeleton
18+
return (
19+
<EmptyState
20+
tone="error"
21+
title="Couldn’t load this part."
22+
body="Check your connection, then try again."
23+
onRetry={retry ?? (() => window.location.reload())}
24+
className={className}
25+
/>
26+
)
27+
}

‎apps/webapp/src/components/TipTap/pad-title-section/PresentQrCode.tsx‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ChunkLoadFallback } from '@components/ChunkLoadFallback'
12
import Button from '@components/ui/Button'
23
import CloseButton from '@components/ui/CloseButton'
34
import { QrVeil } from '@components/ui/QrVeil'
@@ -12,7 +13,12 @@ const ICON_SIZE = 20
1213

1314
const QrCode = dynamic(() => import('@components/ui/QrCode'), {
1415
ssr: false,
15-
loading: () => <div className="skeleton rounded-box aspect-square w-full" />
16+
loading: (p) => (
17+
<ChunkLoadFallback
18+
{...p}
19+
skeleton={<div className="skeleton rounded-box aspect-square w-full" />}
20+
/>
21+
)
1622
})
1723

1824
interface PresentQrCodeProps {
@@ -67,15 +73,18 @@ export function PresentQrCode({ value, title }: PresentQrCodeProps) {
6773

6874
return (
6975
<>
70-
<button
71-
ref={triggerRef}
72-
type="button"
73-
onClick={open}
74-
aria-label={`Present QR code for ${displayUrl}`}
75-
className="group/qr-veil rounded-box focus-visible:ring-primary relative block w-full cursor-pointer focus-visible:ring-2 focus-visible:outline-none max-sm:mx-auto max-sm:max-w-56">
76+
{/* The button sits over the code, not around it, because a failed load puts Try again in this box. */}
77+
<div className="group/qr-box relative w-full max-sm:mx-auto max-sm:max-w-56">
7678
<QrCode value={value} quietZone={2} className="rounded-box w-full" />
77-
<QrVeil icon={Icons.fullscreen} />
78-
</button>
79+
<button
80+
ref={triggerRef}
81+
type="button"
82+
onClick={open}
83+
aria-label={`Present QR code for ${displayUrl}`}
84+
className="group/qr-veil rounded-box focus-visible:ring-primary absolute inset-0 cursor-pointer group-has-[[role=alert]]/qr-box:hidden focus-visible:ring-2 focus-visible:outline-none">
85+
<QrVeil icon={Icons.fullscreen} />
86+
</button>
87+
</div>
7988

8089
<dialog
8190
ref={dialogRef}
@@ -88,7 +97,9 @@ export function PresentQrCode({ value, title }: PresentQrCodeProps) {
8897
ref={stageRef}
8998
onClick={handleStageClick}
9099
className="bg-base-100 relative grid h-full w-full items-center justify-items-center gap-[5vh] px-[5vw] py-[5vh] text-center md:grid-cols-[auto_minmax(0,1fr)] md:justify-items-start md:gap-[5vw] md:text-left">
91-
<QrCode value={value} className="rounded-box w-[min(80vw,55vh)] md:w-[min(86vh,52vw)]" />
100+
<div className="w-[min(80vw,55vh)] md:w-[min(86vh,52vw)]">
101+
<QrCode value={value} className="rounded-box w-full" />
102+
</div>
92103
<div className="grid min-w-0 gap-[2.4vh]">
93104
<p className="text-base-content/60 text-[clamp(0.75rem,1.6vh,1.25rem)] font-semibold tracking-wide uppercase">
94105
Scan to open on your phone

‎apps/webapp/src/components/pages/document/components/PadQrCode.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ChunkLoadFallback } from '@components/ChunkLoadFallback'
12
import { popoverPanelClassName } from '@components/ui/Popover'
23
import { usePadShareUrl } from '@hooks/usePadShareUrl'
34
import { twMerge } from '@utils/twMerge'
@@ -6,7 +7,13 @@ import { type CSSProperties, useEffect, useRef, useState } from 'react'
67

78
const QrCode = dynamic(() => import('@components/ui/QrCode'), {
89
ssr: false,
9-
loading: () => <div className="skeleton rounded-box aspect-square w-full" />
10+
loading: (p) => (
11+
<ChunkLoadFallback
12+
{...p}
13+
skeleton={<div className="skeleton aspect-square w-full rounded-none" />}
14+
className="bg-base-100 px-2 py-4"
15+
/>
16+
)
1017
})
1118

1219
const MIN_SIZE = 128

‎apps/webapp/src/components/settings/SettingsPanel.tsx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ChunkLoadFallback } from '@components/ChunkLoadFallback'
12
import { Avatar } from '@components/ui/Avatar'
23
import Button, { dangerGhostClassName } from '@components/ui/Button'
34
import CloseButton from '@components/ui/CloseButton'
@@ -27,22 +28,22 @@ import {
2728
import type { SettingsPanelProps, SupportInk, SupportRow, TabType } from './types'
2829

2930
const ProfileSection = dynamic(() => import('./components/ProfileSection'), {
30-
loading: () => <ProfileSkeleton />
31+
loading: (p) => <ChunkLoadFallback {...p} skeleton={<ProfileSkeleton />} />
3132
})
3233
const DocumentsSection = dynamic(() => import('./components/DocumentsSection'), {
33-
loading: () => <DocumentsSkeleton />
34+
loading: (p) => <ChunkLoadFallback {...p} skeleton={<DocumentsSkeleton />} />
3435
})
3536
const AppearanceSection = dynamic(() => import('./components/AppearanceSection'), {
36-
loading: () => <AppearanceSkeleton />
37+
loading: (p) => <ChunkLoadFallback {...p} skeleton={<AppearanceSkeleton />} />
3738
})
3839
const SecuritySection = dynamic(() => import('./components/SecuritySection'), {
39-
loading: () => <SecuritySkeleton />
40+
loading: (p) => <ChunkLoadFallback {...p} skeleton={<SecuritySkeleton />} />
4041
})
4142
const NotificationsSection = dynamic(() => import('./components/NotificationsSection'), {
42-
loading: () => <NotificationsSkeleton />
43+
loading: (p) => <ChunkLoadFallback {...p} skeleton={<NotificationsSkeleton />} />
4344
})
4445
const ConnectedAppsSection = dynamic(() => import('./components/ConnectedAppsSection'), {
45-
loading: () => <ConnectedAppsSkeleton />
46+
loading: (p) => <ChunkLoadFallback {...p} skeleton={<ConnectedAppsSkeleton />} />
4647
})
4748

4849
const STAR_SPARK_ANGLES = [0, 60, 120, 180, 240, 300] as const

0 commit comments

Comments
 (0)