jamdesk 1.1.75 → 1.1.77

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.
Files changed (61) hide show
  1. package/dist/__tests__/unit/deps.test.js +184 -0
  2. package/dist/__tests__/unit/deps.test.js.map +1 -1
  3. package/dist/__tests__/unit/dev-spinner-ownership.test.d.ts +2 -0
  4. package/dist/__tests__/unit/dev-spinner-ownership.test.d.ts.map +1 -0
  5. package/dist/__tests__/unit/dev-spinner-ownership.test.js +37 -0
  6. package/dist/__tests__/unit/dev-spinner-ownership.test.js.map +1 -0
  7. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
  8. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
  9. package/dist/__tests__/unit/dev-workspace-symlinks.test.js +112 -0
  10. package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
  11. package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
  12. package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
  13. package/dist/__tests__/unit/language-filter.test.js +166 -0
  14. package/dist/__tests__/unit/language-filter.test.js.map +1 -0
  15. package/dist/__tests__/unit/output.test.d.ts +2 -0
  16. package/dist/__tests__/unit/output.test.d.ts.map +1 -0
  17. package/dist/__tests__/unit/output.test.js +61 -0
  18. package/dist/__tests__/unit/output.test.js.map +1 -0
  19. package/dist/__tests__/unit/spinner.test.d.ts +2 -0
  20. package/dist/__tests__/unit/spinner.test.d.ts.map +1 -0
  21. package/dist/__tests__/unit/spinner.test.js +83 -0
  22. package/dist/__tests__/unit/spinner.test.js.map +1 -0
  23. package/dist/commands/dev.d.ts.map +1 -1
  24. package/dist/commands/dev.js +13 -3
  25. package/dist/commands/dev.js.map +1 -1
  26. package/dist/lib/deps.d.ts +22 -0
  27. package/dist/lib/deps.d.ts.map +1 -1
  28. package/dist/lib/deps.js +121 -27
  29. package/dist/lib/deps.js.map +1 -1
  30. package/dist/lib/language-filter.d.ts +31 -0
  31. package/dist/lib/language-filter.d.ts.map +1 -0
  32. package/dist/lib/language-filter.js +14 -0
  33. package/dist/lib/language-filter.js.map +1 -0
  34. package/dist/lib/spinner.d.ts +24 -0
  35. package/dist/lib/spinner.d.ts.map +1 -1
  36. package/dist/lib/spinner.js +59 -0
  37. package/dist/lib/spinner.js.map +1 -1
  38. package/package.json +3 -3
  39. package/vendored/app/[[...slug]]/page.tsx +12 -4
  40. package/vendored/app/layout.tsx +25 -10
  41. package/vendored/components/mdx/ApiPage.tsx +10 -2
  42. package/vendored/components/mdx/OpenApiEndpoint.tsx +41 -44
  43. package/vendored/components/mdx/YouTube.tsx +8 -0
  44. package/vendored/components/navigation/Sidebar.tsx +32 -17
  45. package/vendored/components/navigation/TabsNav.tsx +22 -30
  46. package/vendored/components/ui/CodePanel.tsx +48 -3
  47. package/vendored/hooks/useIsNavigationSettled.ts +74 -0
  48. package/vendored/lib/layout-helpers.tsx +27 -0
  49. package/vendored/lib/middleware-helpers.ts +79 -8
  50. package/vendored/lib/page-isr-helpers.ts +14 -9
  51. package/vendored/lib/prefetch-batcher.ts +51 -0
  52. package/vendored/lib/prefetch-rsc.ts +19 -0
  53. package/vendored/lib/project-resolver.ts +21 -5
  54. package/vendored/lib/r2-content.ts +16 -0
  55. package/vendored/lib/r2-feature-flags.ts +7 -0
  56. package/vendored/lib/render-doc-page-openapi-helpers.ts +110 -0
  57. package/vendored/lib/render-doc-page-parallel-helpers.ts +60 -0
  58. package/vendored/lib/render-doc-page.tsx +101 -52
  59. package/vendored/lib/sidebar-prefetch-walker.ts +50 -0
  60. package/vendored/lib/static-artifacts.ts +2 -1
  61. package/vendored/workspace-package-lock.json +101 -99
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { ReactNode, useEffect, useRef, useState, useCallback } from 'react';
4
+ import { CODEPANEL_TAB_CHANGE_EVENT } from '@/components/ui/CodePanel';
4
5
 
5
6
  interface ApiPageWrapperProps {
6
7
  children: ReactNode;
@@ -28,6 +29,13 @@ function countCodeLines(panel: Element): number {
28
29
  const codeContent = panel.querySelector('div.grid.grid-cols-\\[minmax\\(0\\,1fr\\)\\]');
29
30
  if (!codeContent) return 0;
30
31
 
32
+ // Loading state: reserve space so the spinner is visible. CodePanel re-fires
33
+ // codepanel-tab-change when the highlighted HTML arrives, which invalidates
34
+ // the cached measurement and brings us back through here for a real count.
35
+ if (codeContent.querySelector('[aria-busy="true"]')) {
36
+ return CODE_BOX_CONFIG.MIN_LINES;
37
+ }
38
+
31
39
  // Check for Shiki-rendered code (has pre.shiki)
32
40
  const shikiPre = codeContent.querySelector('pre.shiki');
33
41
  if (shikiPre) {
@@ -375,8 +383,8 @@ export function ApiPageWrapper({ children }: ApiPageWrapperProps) {
375
383
  };
376
384
 
377
385
  const sidebar = sidebarRef.current;
378
- sidebar.addEventListener('codepanel-tab-change', handleTabChange);
379
- return () => sidebar.removeEventListener('codepanel-tab-change', handleTabChange);
386
+ sidebar.addEventListener(CODEPANEL_TAB_CHANGE_EVENT, handleTabChange);
387
+ return () => sidebar.removeEventListener(CODEPANEL_TAB_CHANGE_EVENT, handleTabChange);
380
388
  }, [isDesktop, mounted, applyPanelHeights]);
381
389
 
382
390
  return (
@@ -4,10 +4,7 @@ import { useDevOnlyNotice, DEV_FEATURE } from '../ui/DevOnlyNotice';
4
4
 
5
5
  import { useState, useEffect, useMemo } from 'react';
6
6
  import { ApiEndpoint } from './ApiEndpoint';
7
- import { ParamField } from './ParamField';
8
- import { CodeGroup } from './CodeGroup';
9
- import { Tabs, Tab } from './Tabs';
10
- import { CodePanel, CodePanelTab, getStatusColor } from '../ui/CodePanel';
7
+ import { CodePanel, CodePanelTab, DelayedSpinner } from '../ui/CodePanel';
11
8
  import { useShikiHighlightMultiple } from '@/hooks/useShikiHighlight';
12
9
  import { preloadHighlighter } from '@/lib/shiki-client';
13
10
  import ReactMarkdown from 'react-markdown';
@@ -76,16 +73,17 @@ function renderSchemaType(schema: JsonSchema): string {
76
73
  return type || 'any';
77
74
  }
78
75
 
76
+ // Shiki HTML comes from server-side highlighting of trusted code strings;
77
+ // sanitization is unnecessary and would break the syntax-color spans.
78
+ function renderHighlightedHtml(html: string) {
79
+ // eslint-disable-next-line react/no-danger
80
+ return <div dangerouslySetInnerHTML={{ __html: html }} />;
81
+ }
82
+
79
83
  /**
80
84
  * Render nested schema properties as a list of field items
81
85
  */
82
- function SchemaProperties({
83
- schema,
84
- depth = 0
85
- }: {
86
- schema: JsonSchema;
87
- depth?: number;
88
- }) {
86
+ function SchemaProperties({ schema }: { schema: JsonSchema }) {
89
87
  if (!schema.properties || Object.keys(schema.properties).length === 0) {
90
88
  return null;
91
89
  }
@@ -596,21 +594,26 @@ function ResponseExamplePanel({
596
594
  codeItems.map((item) => ({ code: item.jsonStr, language: item.language }))
597
595
  );
598
596
 
599
- // Build tabs for CodePanel - render full Shiki HTML for theme background support
597
+ // While Shiki resolves, tab body shows a delayed spinner without the
598
+ // isLoading guard we'd flash the hook's escapeHtml placeholder. CodePanel
599
+ // must mount once with a stable [data-code-panel] (see DelayedSpinner in
600
+ // CodePanel.tsx for the hoist constraint that forced this shape).
600
601
  const tabs: CodePanelTab[] = useMemo(
601
602
  () =>
602
- codeItems.map((item, index) => ({
603
- label: item.code,
604
- statusCode: item.code,
605
- content: (
606
- <div dangerouslySetInnerHTML={{ __html: highlightedResults[index] || '' }} />
607
- ),
608
- })),
609
- [codeItems, highlightedResults]
603
+ codeItems.map((item, index) => {
604
+ const html = highlightedResults[index];
605
+ return {
606
+ label: item.code,
607
+ statusCode: item.code,
608
+ content: !isLoading && html
609
+ ? renderHighlightedHtml(html)
610
+ : <DelayedSpinner />,
611
+ };
612
+ }),
613
+ [codeItems, highlightedResults, isLoading]
610
614
  );
611
615
 
612
- // Don't render until highlighting is complete to prevent flash of unformatted content
613
- if (tabs.length === 0 || isLoading) return null;
616
+ if (tabs.length === 0) return null;
614
617
 
615
618
  return (
616
619
  <CodePanel
@@ -703,13 +706,11 @@ function FieldConstraints({ schema }: { schema: JsonSchema }) {
703
706
  * Response fields list with parent prefix for nested fields
704
707
  * Handles typical API response schemas like { properties: { conference: { type: 'array', items: {...} } } }
705
708
  */
706
- function ResponseFieldsList({
707
- schema,
708
- depth = 0,
709
+ function ResponseFieldsList({
710
+ schema,
709
711
  parentName = ''
710
- }: {
711
- schema: JsonSchema;
712
- depth?: number;
712
+ }: {
713
+ schema: JsonSchema;
713
714
  parentName?: string;
714
715
  }) {
715
716
  // If no properties to show, return null
@@ -729,7 +730,6 @@ function ResponseFieldsList({
729
730
  parentName={parentName}
730
731
  isRequired={required.has(name)}
731
732
  isFirst={index === 0}
732
- depth={depth}
733
733
  />
734
734
  ))}
735
735
  </div>
@@ -745,14 +745,12 @@ function ResponseFieldItem({
745
745
  parentName,
746
746
  isRequired,
747
747
  isFirst,
748
- depth
749
748
  }: {
750
749
  name: string;
751
750
  schema: JsonSchema;
752
751
  parentName: string;
753
752
  isRequired: boolean;
754
753
  isFirst: boolean;
755
- depth: number;
756
754
  }) {
757
755
  const [expanded, setExpanded] = useState(true);
758
756
 
@@ -862,20 +860,21 @@ function CodeExamplesSection({ examples }: { examples: CodeExample[] }) {
862
860
 
863
861
  const { results: highlightedResults, isLoading } = useShikiHighlightMultiple(codeItems);
864
862
 
865
- // Shiki generates HTML from trusted code strings (not user input)
863
+ // While Shiki resolves, tab body shows a delayed spinner without the
864
+ // isLoading guard we'd flash the hook's escapeHtml placeholder.
866
865
  const tabs: CodePanelTab[] = useMemo(
867
- () => examples.map((ex, i) => ({
868
- label: ex.label,
869
- content: highlightedResults[i]
870
- ? <div dangerouslySetInnerHTML={{ __html: highlightedResults[i] }} />
871
- : null,
872
- })),
873
- [examples, highlightedResults]
866
+ () => examples.map((ex, i) => {
867
+ const html = highlightedResults[i];
868
+ return {
869
+ label: ex.label,
870
+ content: !isLoading && html
871
+ ? renderHighlightedHtml(html)
872
+ : <DelayedSpinner />,
873
+ };
874
+ }),
875
+ [examples, highlightedResults, isLoading]
874
876
  );
875
877
 
876
- // Don't render until highlighting is complete to prevent flash of unformatted content
877
- if (isLoading) return null;
878
-
879
878
  return <CodePanel tabs={tabs} variant="compact" panelType="request" enableFullscreen />;
880
879
  }
881
880
 
@@ -883,7 +882,6 @@ function CodeExamplesSection({ examples }: { examples: CodeExample[] }) {
883
882
  * OpenAPI Endpoint Component
884
883
  *
885
884
  * Renders full endpoint documentation from OpenAPI spec data.
886
- * Composes existing ParamField, ResponseField, and CodeGroup components.
887
885
  */
888
886
  export function OpenApiEndpoint({
889
887
  endpoint,
@@ -900,7 +898,6 @@ export function OpenApiEndpoint({
900
898
  const {
901
899
  method,
902
900
  path,
903
- summary,
904
901
  description,
905
902
  deprecated,
906
903
  parameters,
@@ -1,4 +1,5 @@
1
1
  import type React from 'react';
2
+ import { preconnect } from 'react-dom';
2
3
  import { YouTubeEmbed } from '@next/third-parties/google';
3
4
 
4
5
  interface YouTubeProps {
@@ -26,6 +27,7 @@ export function YouTube({ id, title, start, short }: YouTubeProps): React.ReactE
26
27
  const params = startSeconds ? `rel=0&start=${startSeconds}` : 'rel=0';
27
28
 
28
29
  if (short) {
30
+ preconnect('https://www.youtube.com');
29
31
  const src = `https://www.youtube.com/embed/${id}?${params}`;
30
32
  return (
31
33
  <div className="my-6 mx-auto max-w-[360px]">
@@ -41,6 +43,12 @@ export function YouTube({ id, title, start, short }: YouTubeProps): React.ReactE
41
43
  );
42
44
  }
43
45
 
46
+ // No crossOrigin — @next/third-parties loads the lite-yt-embed stylesheet
47
+ // and script in no-CORS mode, so a CORS-anonymous preconnect would open a
48
+ // separate connection pool those fetches can't reuse.
49
+ preconnect('https://i.ytimg.com');
50
+ preconnect('https://cdn.jsdelivr.net');
51
+
44
52
  return (
45
53
  <div className="my-6 [&_lite-youtube]:rounded-xl [&_lite-youtube]:overflow-hidden">
46
54
  <YouTubeEmbed
@@ -44,11 +44,11 @@ interface NavPageProps {
44
44
  layout: LayoutVariant;
45
45
  onNavigate: (href: string) => void;
46
46
  linkPrefix?: string; // e.g., '/docs' when hostAtDocs is true
47
- prefetch?: boolean;
48
47
  showSpinner?: boolean;
48
+ prefetch?: boolean;
49
49
  }
50
50
 
51
- export const NavPage = React.memo(function NavPage({ page, pathname, layout, onNavigate, linkPrefix = '', prefetch = true, showSpinner = false }: NavPageProps) {
51
+ export const NavPage = React.memo(function NavPage({ page, pathname, layout, onNavigate, linkPrefix = '', showSpinner = false, prefetch = true }: NavPageProps) {
52
52
  const href = `${linkPrefix}/${page.path}`;
53
53
  const isActive = pathname === href;
54
54
  const colors = page.method ? methodColors[page.method] : null;
@@ -58,7 +58,14 @@ export const NavPage = React.memo(function NavPage({ page, pathname, layout, onN
58
58
  <Link
59
59
  href={href}
60
60
  prefetch={prefetch}
61
- onClick={() => onNavigate(href)}
61
+ onClick={(e) => {
62
+ // Modifier-clicks open the link in a new tab — current tab does
63
+ // NOT navigate, so don't mark a navigation pending. We only need
64
+ // to filter modifier keys: middle-click on an <a> dispatches
65
+ // `auxclick`, not `click`, so it never reaches this handler.
66
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
67
+ onNavigate(href);
68
+ }}
62
69
  className={`flex items-center gap-2 ${layout === 'header-logo' ? 'pr-3' : 'px-3'} py-1.5 rounded-lg text-sm transition-colors ${
63
70
  isActive
64
71
  ? 'text-[var(--color-primary)] nav-active'
@@ -163,6 +170,14 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
163
170
  rawNavigate(url);
164
171
  }, [rawNavigate]);
165
172
 
173
+ // Buttons (group header, anchor, tab) need router.push because they're
174
+ // not <Link>s — Link's own click handler does the navigation, buttons must.
175
+ const handleButtonNav = useCallback((url: string) => {
176
+ userInitiatedNavRef.current = true;
177
+ rawNavigate(url);
178
+ router.push(url);
179
+ }, [rawNavigate, router]);
180
+
166
181
  const effectivePathname = pendingPathname || pathname;
167
182
 
168
183
  // Determine effective tabsPosition:
@@ -324,23 +339,27 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
324
339
  });
325
340
  if (willExpand) {
326
341
  const firstPagePath = findFirstPageInGroups([group]);
327
- if (firstPagePath) router.push(`${linkPrefix}/${firstPagePath}`);
342
+ if (firstPagePath) {
343
+ handleButtonNav(`${linkPrefix}/${firstPagePath}`);
344
+ }
328
345
  }
329
- }, [router, linkPrefix, expandedGroups]);
346
+ }, [linkPrefix, expandedGroups, handleButtonNav]);
330
347
 
331
348
  // Render a navigation group (supports nesting)
332
349
  function renderGroup(group: ResolvedGroup, level: number = 0) {
333
350
  const isExpanded = expandedGroups.has(group.name);
334
- // expandedGroups starts empty and is populated in a useEffect, so the very
335
- // first render has shouldPrefetch=false for every L0 section; Next.js
336
- // re-schedules the prefetch when the prop flips to true on the next commit.
337
- const shouldPrefetch = level !== 0 || isExpanded || !group.name;
338
351
 
339
352
  // Skip rendering if group has no name and no content
340
353
  if (!group.name && group.pages.length === 0 && !group.nested?.length && !group.items?.length) {
341
354
  return null;
342
355
  }
343
356
 
357
+ // Don't fan out RSC prefetches for collapsed L0 sections on large docs
358
+ // sites. L1+ Links are only in the DOM when their named ancestors are
359
+ // expanded (see the rendering guard below), so `level !== 0` is safe
360
+ // shorthand there.
361
+ const shouldPrefetch = level !== 0 || isExpanded || !group.name;
362
+
344
363
  return (
345
364
  <div key={group.name || `group-${level}`} className={level === 0 ? 'ml-6' : level === 1 ? '' : 'ml-3'}>
346
365
  {group.name && (
@@ -402,8 +421,8 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
402
421
  layout={layout}
403
422
  onNavigate={handleNavigate}
404
423
  linkPrefix={linkPrefix}
405
- prefetch={shouldPrefetch}
406
424
  showSpinner={spinnerPathname === `${linkPrefix}/${p.page.path}`}
425
+ prefetch={shouldPrefetch}
407
426
  />
408
427
  ))}
409
428
  </ul>
@@ -442,8 +461,8 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
442
461
  layout={layout}
443
462
  onNavigate={handleNavigate}
444
463
  linkPrefix={linkPrefix}
445
- prefetch={shouldPrefetch}
446
464
  showSpinner={spinnerPathname === `${linkPrefix}/${page.path}`}
465
+ prefetch={shouldPrefetch}
447
466
  />
448
467
  ))}
449
468
  </ul>
@@ -495,9 +514,7 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
495
514
  data-nav-anchor
496
515
  data-active={isCurrentAnchor ? 'true' : 'false'}
497
516
  onClick={() => {
498
- if (firstPagePath) {
499
- router.push(`${linkPrefix}/${firstPagePath}`);
500
- }
517
+ if (firstPagePath) handleButtonNav(`${linkPrefix}/${firstPagePath}`);
501
518
  }}
502
519
  className={`flex items-center gap-2.5 ml-6 pr-3 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
503
520
  isCurrentAnchor
@@ -606,9 +623,7 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
606
623
  <button
607
624
  key={tab.name}
608
625
  onClick={() => {
609
- if (firstPagePath) {
610
- router.push(`${linkPrefix}/${firstPagePath}`);
611
- }
626
+ if (firstPagePath) handleButtonNav(`${linkPrefix}/${firstPagePath}`);
612
627
  }}
613
628
  className={`nav-tab-link flex items-center gap-2 px-3 py-1.5 text-sm transition-colors cursor-pointer ${
614
629
  isActive
@@ -5,7 +5,7 @@ import Link from 'next/link';
5
5
  import { usePathname } from 'next/navigation';
6
6
  // Icons use Font Awesome CSS classes for lightweight rendering
7
7
  import type { DocsConfig, TabsPosition } from '@/lib/docs-types';
8
- import { resolveNavigation, type ResolvedTab, type ResolvedGroup } from '@/lib/navigation-resolver';
8
+ import { resolveNavigation } from '@/lib/navigation-resolver';
9
9
  import { getIconClass } from '@/lib/icon-utils';
10
10
  import { getTheme } from '@/themes';
11
11
  import { getTabsFromConfig } from '@/lib/navigation-utils';
@@ -24,38 +24,25 @@ export function TabsNav({ config, className = '' }: TabsNavProps) {
24
24
  const themeConfig = getTheme(config.theme);
25
25
  const effectiveTabsPosition: TabsPosition = config.tabsPosition || themeConfig.defaultTabsPosition;
26
26
 
27
- // TabsNav is only for 'top' position - when tabs should appear below header
28
- // When tabsPosition is 'left', tabs are rendered in the Sidebar instead
29
- if (effectiveTabsPosition !== 'top') {
30
- return null;
31
- }
32
-
33
27
  // Resolve navigation to get tabs and their content
34
28
  const resolved = useMemo(() => resolveNavigation(config, pathname), [config, pathname]);
35
29
 
36
- // Get tabs from config (handles both standard and multi-language formats)
37
- const configTabs = getTabsFromConfig(config, pathname);
30
+ // Memoize so the tabsWithPaths memo's deps stay stable across renders.
31
+ const configTabs = useMemo(() => getTabsFromConfig(config, pathname), [config, pathname]);
38
32
 
39
- // Only render if we have tabs
40
- if (configTabs.length === 0) {
41
- return null;
42
- }
43
-
44
- // Build tab data with first page paths for linking
33
+ // Build tab data with first page paths for linking. Skip the work when
34
+ // tabsPosition isn't 'top' — we'd return null below anyway, but the
35
+ // useMemo must run unconditionally for rules-of-hooks.
45
36
  const tabsWithPaths = useMemo(() => {
46
- const tabs = configTabs;
47
-
48
- return tabs.map((tab, index) => {
37
+ if (effectiveTabsPosition !== 'top') return [];
38
+
39
+ return configTabs.flatMap((tab, index) => {
49
40
  const resolvedTab = resolved.tabs[index];
50
-
51
- // Get groups for this tab to find first page and check active state
52
- const tabGroups: ResolvedGroup[] = [];
53
- if (tab.groups) {
54
- // Need to resolve groups from the original tab config
55
- // For now, we'll look them up in resolved.groups
56
- // This is a simplification - in a full implementation we'd track per-tab groups
57
- }
58
-
41
+ // configTabs and resolved.tabs select the active language independently;
42
+ // their fallbacks for empty-tab languages differ, so guard against
43
+ // length mismatch rather than throw on `.isExternal` of undefined.
44
+ if (!resolvedTab) return [];
45
+
59
46
  // For external tabs, use the href directly
60
47
  if (resolvedTab.isExternal && resolvedTab.href) {
61
48
  return {
@@ -132,9 +119,14 @@ export function TabsNav({ config, className = '' }: TabsNavProps) {
132
119
  isActive,
133
120
  };
134
121
  });
135
- }, [configTabs, resolved.tabs, pathname, linkPrefix]);
136
-
137
- // If only one tab, don't show tabs nav
122
+ }, [configTabs, resolved.tabs, pathname, linkPrefix, effectiveTabsPosition]);
123
+
124
+ // TabsNav is only for 'top' position - when tabs should appear below header
125
+ // When tabsPosition is 'left', tabs are rendered in the Sidebar instead
126
+ if (effectiveTabsPosition !== 'top') {
127
+ return null;
128
+ }
129
+
138
130
  if (tabsWithPaths.length <= 1) {
139
131
  return null;
140
132
  }
@@ -42,6 +42,9 @@ export interface CodePanelProps {
42
42
  className?: string;
43
43
  }
44
44
 
45
+ // Bubbles up to the ApiPage sidebar listener; load-bearing for resize-on-content-change.
46
+ export const CODEPANEL_TAB_CHANGE_EVENT = 'codepanel-tab-change';
47
+
45
48
  /**
46
49
  * CSS color variables with hardcoded fallbacks
47
50
  * Themes can override via --code-panel-* variables
@@ -150,10 +153,13 @@ export function CodePanel({
150
153
  }
151
154
  };
152
155
 
153
- // Notify parent (ApiPage) after tab content re-renders so sidebar heights recalculate
156
+ // ApiPage caches each panel's line count on first measure; we re-fire on
157
+ // active-tab content change so the spinner-era height is invalidated when
158
+ // async Shiki finishes.
159
+ const currentTabContent = tabs[activeTab]?.content;
154
160
  useEffect(() => {
155
- panelRef.current?.dispatchEvent(new CustomEvent('codepanel-tab-change', { bubbles: true }));
156
- }, [activeTab]);
161
+ panelRef.current?.dispatchEvent(new CustomEvent(CODEPANEL_TAB_CHANGE_EVENT, { bubbles: true }));
162
+ }, [activeTab, currentTabContent]);
157
163
 
158
164
  // Check for overflow and scroll position
159
165
  useEffect(() => {
@@ -255,6 +261,8 @@ export function CodePanel({
255
261
  ref={panelRef}
256
262
  className={`rounded-xl overflow-hidden not-prose w-full flex flex-col ${className}`}
257
263
  style={{ border: `0.5px solid ${codePanelColors.border}`, boxShadow: 'var(--shadow-lg)' }}
264
+ // Load-bearing: ApiPage's MutationObserver hoists [data-code-panel]
265
+ // into the sticky right column. Don't drop or rename this attribute.
258
266
  data-code-panel={panelType || 'inline'}
259
267
  >
260
268
  {/* Tabs header - flex-shrink-0 to maintain size */}
@@ -522,3 +530,40 @@ export function CodePanel({
522
530
  </div>
523
531
  );
524
532
  }
533
+
534
+ interface DelayedSpinnerProps {
535
+ /** Delay before showing the spinner — avoids flash on fast loads. */
536
+ delayMs?: number;
537
+ /** Min body height to reserve so layout doesn't jump on first paint. */
538
+ minHeight?: number;
539
+ }
540
+
541
+ // Use this as `content` of a CodePanel tab while async work resolves. Earlier
542
+ // versions had a separate CodePanelSkeleton component, but ApiPage's
543
+ // MutationObserver hoists `[data-code-panel]` nodes into the sticky sidebar
544
+ // — when React then tried to unmount the skeleton from prose, the node was
545
+ // already in the sidebar and removeChild threw NotFoundError. Swapping tab
546
+ // content keeps the outer `[data-code-panel]` mounted so the hoist happens
547
+ // exactly once.
548
+ export function DelayedSpinner({ delayMs = 500, minHeight = 60 }: DelayedSpinnerProps) {
549
+ const [show, setShow] = useState(false);
550
+ useEffect(() => {
551
+ const t = setTimeout(() => setShow(true), delayMs);
552
+ return () => clearTimeout(t);
553
+ }, [delayMs]);
554
+
555
+ return (
556
+ <div
557
+ className="flex items-center justify-center py-3"
558
+ style={{ minHeight, color: codePanelColors.textMuted }}
559
+ aria-busy="true"
560
+ aria-live="polite"
561
+ >
562
+ <i
563
+ className={`fa-solid fa-circle-notch fa-spin text-base transition-opacity duration-200 ${show ? 'opacity-60' : 'opacity-0'}`}
564
+ aria-hidden="true"
565
+ />
566
+ <span className="sr-only">Loading code example</span>
567
+ </div>
568
+ );
569
+ }
@@ -0,0 +1,74 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ // setTimeout(0) yields one frame so the page's initial RSC paint commits
6
+ // before the prefetch chain kicks off.
7
+ export const SETTLED_DELAY_MS = 0;
8
+
9
+ /**
10
+ * Boolean signal: "is it safe to prefetch sidebar links right now?"
11
+ *
12
+ * True iff:
13
+ * - pendingPathname is null (no click-in-flight), AND
14
+ * - document.readyState === 'complete', AND
15
+ * - one frame has elapsed since the last pathname change (setTimeout 0).
16
+ *
17
+ * Resets to false on any input change. The setTimeout(0) frame yield lets
18
+ * the page's initial RSC render commit before we kick off the prefetch
19
+ * chain — without it, sidebar prefetches can race the page's own data
20
+ * fetches inside the same Vercel function pool.
21
+ */
22
+ export function useIsNavigationSettled(
23
+ pathname: string | null,
24
+ pendingPathname: string | null,
25
+ ): boolean {
26
+ const [settled, setSettled] = useState(false);
27
+
28
+ useEffect(() => {
29
+ setSettled(false);
30
+
31
+ if (typeof document === 'undefined') return;
32
+
33
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
34
+
35
+ const startTimer = () => {
36
+ timeoutId = setTimeout(() => {
37
+ setSettled(true);
38
+ }, SETTLED_DELAY_MS);
39
+ };
40
+
41
+ const cleanup = () => {
42
+ if (timeoutId !== null) {
43
+ clearTimeout(timeoutId);
44
+ timeoutId = null;
45
+ }
46
+ };
47
+
48
+ // Don't pace until the user has nothing pending and the page has loaded.
49
+ if (pendingPathname !== null) {
50
+ return cleanup;
51
+ }
52
+
53
+ if (document.readyState === 'complete') {
54
+ startTimer();
55
+ return cleanup;
56
+ }
57
+
58
+ // Wait for the load event, then start the stability timer.
59
+ const onLoad = () => {
60
+ window.removeEventListener('load', onLoad);
61
+ startTimer();
62
+ };
63
+ window.addEventListener('load', onLoad);
64
+
65
+ return () => {
66
+ window.removeEventListener('load', onLoad);
67
+ cleanup();
68
+ };
69
+ // pathname dep handles back/forward and deep-link navs that bypass
70
+ // the click handlers (no pendingPathname flip).
71
+ }, [pathname, pendingPathname]);
72
+
73
+ return settled;
74
+ }
@@ -25,6 +25,7 @@ import type { DocsConfig, FontConfig, LanguageCode } from '@/lib/docs-types';
25
25
  import { LinkPrefixProvider } from '@/lib/link-prefix-context';
26
26
  import { ProjectSlugProvider } from '@/lib/project-slug-context';
27
27
  import { getAnalyticsScript } from '@/lib/analytics-script';
28
+ import { fetchCustomCss, fetchCustomJs } from '@/lib/r2-content';
28
29
 
29
30
  const scrollLockBootstrap = `
30
31
  (function() {
@@ -76,6 +77,32 @@ export function getLocalFileContent(filename: string): string | null {
76
77
  return null;
77
78
  }
78
79
 
80
+ interface LoadCustomAssetsInput {
81
+ projectSlug: string;
82
+ hasCustomCss: boolean;
83
+ hasCustomJs: boolean;
84
+ }
85
+
86
+ interface LoadedCustomAssets {
87
+ customCss: string | null;
88
+ customJs: string | null;
89
+ }
90
+
91
+ /**
92
+ * Both fetches run in parallel — sequential awaits add one cross-continent
93
+ * R2 RTT in non-NA regions (~340ms fra1, ~550ms sin1).
94
+ */
95
+ export async function loadCustomAssets({
96
+ projectSlug,
97
+ hasCustomCss,
98
+ hasCustomJs,
99
+ }: LoadCustomAssetsInput): Promise<LoadedCustomAssets> {
100
+ const cssP = hasCustomCss ? fetchCustomCss(projectSlug) : Promise.resolve(null);
101
+ const jsP = hasCustomJs ? fetchCustomJs(projectSlug) : Promise.resolve(null);
102
+ const [customCss, customJs] = await Promise.all([cssP, jsP]);
103
+ return { customCss, customJs };
104
+ }
105
+
79
106
  export function getThemeCssContent(themeName: ThemeName | undefined): string | null {
80
107
  try {
81
108
  const theme = getTheme(themeName);