Skip to content

Commit 382b935

Browse files
docs: S2 docs skeleton loading improvements (#9186)
* add delay for showing page skeleton * use fake image for SkeletonVisualExample in PageSkeleton * fix optimistic rendering of the sidenav * move delay into skeleton and fix clicking already selected link --------- Co-authored-by: Devon Govett <devongovett@gmail.com>
1 parent 46eb0ef commit 382b935

File tree

6 files changed

+143
-173
lines changed

6 files changed

+143
-173
lines changed

packages/dev/s2-docs/src/Layout.tsx

Lines changed: 80 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {ExampleList} from './ExampleList';
2-
import {Nav, PendingPageProvider} from '../src/Nav';
2+
import {Nav} from '../src/Nav';
33
import {OptimisticMobileToc, OptimisticToc} from './OptimisticToc';
44
import type {Page, PageProps} from '@parcel/rsc';
55
import React, {ReactElement} from 'react';
@@ -254,93 +254,91 @@ export function Layout(props: PageProps & {children: ReactElement<any>}) {
254254
}
255255
})}>
256256
<Header pages={pages} currentPage={currentPage} />
257-
<PendingPageProvider currentPage={currentPage}>
258-
<MobileHeader
259-
toc={<OptimisticMobileToc currentPage={currentPage} />}
260-
pages={pages}
261-
currentPage={currentPage} />
262-
<div className={style({display: 'flex', width: 'full', flexGrow: {default: 1, lg: 0}})}>
263-
{currentPage.exports?.hideNav ? null : <Nav pages={pages} currentPage={currentPage} />}
264-
<main
265-
key={currentPage.url}
266-
style={{borderBottomLeftRadius: 0, borderBottomRightRadius: 0}}
257+
<MobileHeader
258+
toc={<OptimisticMobileToc currentPage={currentPage} pages={pages} />}
259+
pages={pages}
260+
currentPage={currentPage} />
261+
<div className={style({display: 'flex', width: 'full', flexGrow: {default: 1, lg: 0}})}>
262+
{currentPage.exports?.hideNav ? null : <Nav pages={pages} currentPage={currentPage} />}
263+
<main
264+
key={currentPage.url}
265+
style={{borderBottomLeftRadius: 0, borderBottomRightRadius: 0}}
266+
className={style({
267+
isolation: 'isolate',
268+
backgroundColor: 'base',
269+
padding: {
270+
default: 12,
271+
lg: 40
272+
},
273+
borderRadius: {
274+
default: 'none',
275+
lg: 'xl'
276+
},
277+
boxShadow: {
278+
lg: 'emphasized'
279+
},
280+
width: 'full',
281+
boxSizing: 'border-box',
282+
flexGrow: 1,
283+
display: 'flex',
284+
justifyContent: 'space-between',
285+
position: 'relative',
286+
height: {
287+
lg: '[calc(100vh - 72px)]'
288+
},
289+
overflow: {
290+
lg: 'auto'
291+
}
292+
})}>
293+
<div
267294
className={style({
268-
isolation: 'isolate',
269-
backgroundColor: 'base',
270-
padding: {
271-
default: 12,
272-
lg: 40
273-
},
274-
borderRadius: {
275-
default: 'none',
276-
lg: 'xl'
277-
},
278-
boxShadow: {
279-
lg: 'emphasized'
280-
},
281-
width: 'full',
282-
boxSizing: 'border-box',
283-
flexGrow: 1,
284295
display: 'flex',
285-
justifyContent: 'space-between',
286-
position: 'relative',
296+
flexDirection: 'column',
297+
flexGrow: 1,
298+
width: 'full'
299+
})}>
300+
<CodePlatterProvider library={getLibraryFromUrl(currentPage.url)}>
301+
<NavigationSuspense pages={pages}>
302+
<article
303+
className={articleStyles({isWithToC: hasToC})}>
304+
{currentPage.exports?.version && <VersionBadge version={currentPage.exports.version} />}
305+
{React.cloneElement(children, {
306+
components: isSubpage ?
307+
subPageComponents(parentPage) :
308+
components,
309+
pages
310+
})}
311+
{currentPage.exports?.relatedPages && (
312+
<MobileRelatedPages pages={currentPage.exports.relatedPages} />
313+
)}
314+
</article>
315+
</NavigationSuspense>
316+
</CodePlatterProvider>
317+
<Footer />
318+
</div>
319+
{hasToC && <aside
320+
className={style({
321+
position: 'sticky',
322+
top: 0,
287323
height: {
324+
default: 'fit',
288325
lg: '[calc(100vh - 72px)]'
289326
},
290-
overflow: {
291-
lg: 'auto'
292-
}
327+
paddingY: 32,
328+
paddingX: 4,
329+
boxSizing: 'border-box',
330+
width: 180,
331+
flexShrink: 0,
332+
display: {
333+
default: 'none',
334+
lg: 'flex'
335+
},
336+
flexDirection: 'column'
293337
})}>
294-
<div
295-
className={style({
296-
display: 'flex',
297-
flexDirection: 'column',
298-
flexGrow: 1,
299-
width: 'full'
300-
})}>
301-
<CodePlatterProvider library={getLibraryFromUrl(currentPage.url)}>
302-
<NavigationSuspense pages={pages}>
303-
<article
304-
className={articleStyles({isWithToC: hasToC})}>
305-
{currentPage.exports?.version && <VersionBadge version={currentPage.exports.version} />}
306-
{React.cloneElement(children, {
307-
components: isSubpage ?
308-
subPageComponents(parentPage) :
309-
components,
310-
pages
311-
})}
312-
{currentPage.exports?.relatedPages && (
313-
<MobileRelatedPages pages={currentPage.exports.relatedPages} />
314-
)}
315-
</article>
316-
</NavigationSuspense>
317-
</CodePlatterProvider>
318-
<Footer />
319-
</div>
320-
{hasToC && <aside
321-
className={style({
322-
position: 'sticky',
323-
top: 0,
324-
height: {
325-
default: 'fit',
326-
lg: '[calc(100vh - 72px)]'
327-
},
328-
paddingY: 32,
329-
paddingX: 4,
330-
boxSizing: 'border-box',
331-
width: 180,
332-
flexShrink: 0,
333-
display: {
334-
default: 'none',
335-
lg: 'flex'
336-
},
337-
flexDirection: 'column'
338-
})}>
339-
<OptimisticToc currentPage={currentPage} />
340-
</aside>}
341-
</main>
342-
</div>
343-
</PendingPageProvider>
338+
<OptimisticToc currentPage={currentPage} pages={pages} />
339+
</aside>}
340+
</main>
341+
</div>
344342
</div>
345343
<ToastContainer placement="bottom" />
346344
</body>

packages/dev/s2-docs/src/Nav.tsx

Lines changed: 8 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,11 @@
22

33
import {focusRing, size, style} from '@react-spectrum/s2/style' with {type: 'macro'};
44
import {getLibraryFromPage} from './library';
5+
import {getPageFromPathname, getSnapshot, subscribe} from './NavigationSuspense';
56
import {Link} from 'react-aria-components';
67
import type {Page, PageProps} from '@parcel/rsc';
78
import {Picker, pressScale} from '@react-spectrum/s2';
8-
import React, {createContext, startTransition, useContext, useEffect, useOptimistic, useRef, useState} from 'react';
9-
10-
export function PendingPageProvider({children, currentPage}: {children: React.ReactNode, currentPage: Page}) {
11-
let [displayPage, setDisplayPage] = useOptimistic(
12-
currentPage,
13-
(_, pendingPage: Page) => pendingPage
14-
);
15-
16-
useEffect(() => {
17-
const unsubscribe = subscribeToClearPendingPage(() => {
18-
startTransition(() => {
19-
setDisplayPage(currentPage);
20-
});
21-
});
22-
return unsubscribe;
23-
}, [currentPage, setDisplayPage]);
24-
25-
let pendingPage = displayPage.url !== currentPage.url ? displayPage : null;
26-
27-
return (
28-
<PendingPageContext.Provider value={pendingPage}>
29-
<PendingNavContext.Provider value={setDisplayPage}>
30-
{children}
31-
</PendingNavContext.Provider>
32-
</PendingPageContext.Provider>
33-
);
34-
}
9+
import React, {createContext, useContext, useEffect, useRef, useState, useSyncExternalStore} from 'react';
3510

3611
export function Nav({pages, currentPage}: PageProps) {
3712
let currentLibrary = getLibraryFromPage(currentPage);
@@ -56,7 +31,8 @@ export function Nav({pages, currentPage}: PageProps) {
5631
}
5732

5833
let [maskSize, setMaskSize] = useState(0);
59-
let pendingPage = usePendingPage();
34+
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
35+
const pendingPage = snapshot.pathname ? getPageFromPathname(pages, snapshot.pathname) : null;
6036
let displayUrl = pendingPage?.url ?? currentPage.url;
6137

6238
let sortedSections = [...sections].sort((a, b) => {
@@ -137,24 +113,10 @@ function SideNavSection({title, children}) {
137113
}
138114

139115
const SideNavContext = createContext('');
140-
const PendingNavContext = createContext<React.Dispatch<Page> | null>(null);
141-
const PendingPageContext = createContext<Page | null>(null);
142-
143-
let clearPendingPageListeners = new Set<() => void>();
144-
145-
function subscribeToClearPendingPage(callback: () => void): () => void {
146-
clearPendingPageListeners.add(callback);
147-
return () => {
148-
void clearPendingPageListeners.delete(callback);
149-
};
150-
}
151116

152-
export function clearPendingPage() {
153-
clearPendingPageListeners.forEach(callback => callback());
154-
}
155-
156-
export function usePendingPage() {
157-
return useContext(PendingPageContext);
117+
export function usePendingPage(pages: Page[]): Page | null {
118+
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
119+
return snapshot.pathname ? getPageFromPathname(pages, snapshot.pathname) : null;
158120
}
159121

160122
export function SideNav({children, isNested = false}) {
@@ -194,22 +156,14 @@ export function SideNavItem(props) {
194156
export function SideNavLink(props) {
195157
let linkRef = useRef(null);
196158
let selected = useContext(SideNavContext);
197-
let setPendingPage = useContext(PendingNavContext);
198-
let {page, ...linkProps} = props;
159+
let {...linkProps} = props;
199160

200161
return (
201162
<Link
202163
{...linkProps}
203164
ref={linkRef}
204165
aria-current={props.isSelected || selected === props.href ? 'page' : undefined}
205166
style={pressScale(linkRef)}
206-
onPress={() => {
207-
if (setPendingPage && page) {
208-
startTransition(() => {
209-
setPendingPage(page);
210-
});
211-
}
212-
}}
213167
className={style({
214168
...focusRing(),
215169
minHeight: 32,

packages/dev/s2-docs/src/NavigationSuspense.tsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@
22

33
import type {Page} from '@parcel/rsc';
44
import {PageSkeleton} from './PageSkeleton';
5-
import React, {Suspense, use, useSyncExternalStore} from 'react';
5+
import React, {Suspense, use, useEffect, useState, useSyncExternalStore} from 'react';
6+
7+
const SKELETON_DELAY = 150;
68

79
let navigationPromise: Promise<void> | null = null;
810
let targetPathname: string | null = null;
911
let listeners = new Set<() => void>();
1012
let cachedSnapshot: {promise: Promise<void> | null, pathname: string | null} = {promise: null, pathname: null};
1113

12-
function subscribe(callback: () => void) {
14+
export function subscribe(callback: () => void) {
1315
listeners.add(callback);
1416
return () => listeners.delete(callback);
1517
}
1618

17-
function getSnapshot() {
19+
export function getSnapshot() {
1820
if (cachedSnapshot.promise !== navigationPromise || cachedSnapshot.pathname !== targetPathname) {
1921
cachedSnapshot = {promise: navigationPromise, pathname: targetPathname};
2022
}
@@ -66,9 +68,9 @@ function getPageTitle(page: Page): string {
6668
return page.exports?.title ?? page.tableOfContents?.[0]?.title ?? page.name;
6769
}
6870

69-
function getPageInfo(pages: Page[], pathname: string | null): {title?: string, section?: string, hasToC?: boolean} {
71+
export function getPageFromPathname(pages: Page[], pathname: string | null): Page | null {
7072
if (!pathname) {
71-
return {};
73+
return null;
7274
}
7375

7476
let publicUrl = process.env.PUBLIC_URL || '/';
@@ -85,6 +87,12 @@ function getPageInfo(pages: Page[], pathname: string | null): {title?: string, s
8587
normalizedPageUrl === normalizedPathname + '.html';
8688
});
8789

90+
return targetPage ?? null;
91+
}
92+
93+
function getPageInfo(pages: Page[], pathname: string | null): {title?: string, section?: string, hasToC?: boolean} {
94+
const targetPage = getPageFromPathname(pages, pathname);
95+
8896
if (!targetPage) {
8997
return {};
9098
}
@@ -99,9 +107,22 @@ function getPageInfo(pages: Page[], pathname: string | null): {title?: string, s
99107
function NavigationContent({children}: {children: React.ReactNode}) {
100108
// Subscribe to navigation promise changes to ensure React re-renders when setNavigationPromise() is called.
101109
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
102-
if (snapshot.promise) {
103-
use(snapshot.promise);
110+
let [delayedPromise, setDelayedPromise] = useState<Promise<void> | null>(null);
111+
useEffect(() => {
112+
let promise = snapshot.promise;
113+
if (!promise) {
114+
return;
115+
}
116+
let timeout = setTimeout(() => {
117+
setDelayedPromise(promise);
118+
}, SKELETON_DELAY);
119+
return () => clearTimeout(timeout);
120+
}, [snapshot]);
121+
122+
if (delayedPromise) {
123+
use(delayedPromise);
104124
}
125+
105126
return <>{children}</>;
106127
}
107128

packages/dev/s2-docs/src/OptimisticToc.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ function renderMobileToc(toc: TocNode[], seen = new Map()) {
4343
});
4444
}
4545

46-
export function OptimisticToc({currentPage}: {currentPage: Page}) {
47-
let pendingPage = usePendingPage();
46+
export function OptimisticToc({currentPage, pages}: {currentPage: Page, pages: Page[]}) {
47+
let pendingPage = usePendingPage(pages);
4848
let displayPage = pendingPage ?? currentPage;
4949

5050
return (
@@ -61,8 +61,8 @@ export function OptimisticToc({currentPage}: {currentPage: Page}) {
6161
);
6262
}
6363

64-
export function OptimisticMobileToc({currentPage}: {currentPage: Page}) {
65-
let pendingPage = usePendingPage();
64+
export function OptimisticMobileToc({currentPage, pages}: {currentPage: Page, pages: Page[]}) {
65+
let pendingPage = usePendingPage(pages);
6666
let displayPage = pendingPage ?? currentPage;
6767

6868
if ((displayPage.tableOfContents?.[0]?.children?.length ?? 0) <= 1) {

0 commit comments

Comments
 (0)