diff --git a/src/components/LibrariesMenuContent.tsx b/src/components/LibrariesMenuContent.tsx new file mode 100644 index 000000000..2cb1b3af6 --- /dev/null +++ b/src/components/LibrariesMenuContent.tsx @@ -0,0 +1,263 @@ +import * as React from 'react' +import { Link } from '@tanstack/react-router' +import { twMerge } from 'tailwind-merge' +import { GridFourIcon } from '@phosphor-icons/react/GridFour' +import { ArrowRightIcon } from '@phosphor-icons/react/ArrowRight' +import { Button } from '~/components/ds/ui' +import { useLibrariesOverlay } from '~/contexts/LibrariesOverlayContext' +import { publicLibraries, type LibrarySlim } from '~/libraries' +import { + categoryLabels, + categoryOrder, + categoryTextColor, + libraryCategories, + type LibraryCategory, +} from '~/libraries/categories' +import { fallbackLibraryIcon, libraryIcons } from '~/libraries/icons' + +type IconComponent = React.ComponentType<{ className?: string }> + +function getLibraryDisplayName(library: LibrarySlim) { + return library.name.replace(/^TanStack\s+/, '') +} + +type LibraryMenuEntry = { + id: string + name: string + to: string + icon: IconComponent + /** `group-hover/lib:text-category-*` — recolors the icon to its category. */ + iconHoverColor: string +} + +// Full static class strings (Tailwind can't see composed names) mapping each +// category to the hover color applied to a library's icon in the mega-menu. +const categoryIconHoverColor: Record = { + framework: 'group-hover/lib:text-category-framework', + data: 'group-hover/lib:text-category-data', + ui: 'group-hover/lib:text-category-ui', + performance: 'group-hover/lib:text-category-performance', + tooling: 'group-hover/lib:text-category-tooling', +} + +type LibraryMenuColumn = { + category: LibraryCategory + label: string + colorClass: string + libraries: LibraryMenuEntry[] +} + +/** + * The Libraries mega-menu as five category columns (Framework, Data & State, + * UI & UX, Performance, Tooling), built from the canonical `libraryCategories` + * taxonomy. Iterating `libraryCategories` preserves the intended per-category + * order; only public, navigable libraries are shown. + */ +function getLibraryCategoryColumns(): LibraryMenuColumn[] { + const byCategory = new Map( + categoryOrder.map((category) => [category, []]), + ) + + for (const [id, category] of Object.entries(libraryCategories)) { + const library = publicLibraries.find((lib) => lib.id === id) + if (!library || !library.to) continue + byCategory.get(category)?.push({ + id: library.id, + name: getLibraryDisplayName(library), + to: library.to, + icon: libraryIcons[library.id] ?? fallbackLibraryIcon, + iconHoverColor: categoryIconHoverColor[category], + }) + } + + return categoryOrder + .map((category) => ({ + category, + label: categoryLabels[category], + colorClass: categoryTextColor[category], + libraries: byCategory.get(category) ?? [], + })) + .filter((column) => column.libraries.length > 0) +} + +export function LibrariesMenuContent({ + onNavigate, + variant, +}: { + onNavigate: () => void + variant: 'desktop' | 'mobile' +}) { + const { openLibraries } = useLibrariesOverlay() + const columns = getLibraryCategoryColumns() + + const allLibraries = ( + + ) + + if (variant === 'mobile') { + return ( +
+ {columns.map((column) => ( + + ))} + {allLibraries} +
+ ) + } + + return ( +
+
+ {columns.map((column) => ( + + ))} +
+
+ {allLibraries} +
+
+ ) +} + +function LibraryCategoryColumn({ + column, + onNavigate, + variant, +}: { + column: LibraryMenuColumn + onNavigate: () => void + variant: 'desktop' | 'mobile' +}) { + return ( +
+
+ {column.label} +
+
+ {column.libraries.map((library) => ( + + ))} +
+
+ ) +} + +function LibraryMenuRow({ + library, + onNavigate, + variant, +}: { + library: LibraryMenuEntry + onNavigate: () => void + variant: 'desktop' | 'mobile' +}) { + const Icon = library.icon + const external = library.to.startsWith('http') + const className = twMerge( + // Light mode: an "elevated white" hover — a bright-white pill lifted off the + // glass with a soft shadow + hairline ring (contrast via depth, not value). + // Dark mode keeps the subtle white/4% (pressed 12%) overlay, no shadow/ring. + 'group/lib flex items-center gap-2 rounded-[14px] py-2 pl-[9px] pr-4 text-text-secondary transition-[color,background-color,box-shadow] hover:bg-white hover:text-text-primary hover:shadow-sm hover:ring-1 hover:ring-black/5 focus:bg-white focus:text-text-primary focus:shadow-sm focus:ring-1 focus:ring-black/5 focus:outline-none active:bg-white dark:hover:bg-text-primary/[0.04] dark:hover:shadow-none dark:hover:ring-0 dark:focus:bg-text-primary/[0.04] dark:focus:shadow-none dark:focus:ring-0 dark:active:bg-text-primary/[0.12]', + variant === 'desktop' + ? 'h-[38px] min-[1120px]:h-[46px] min-[1120px]:gap-2.5 min-[1120px]:rounded-[17px] min-[1120px]:pl-[11px] min-[1120px]:pr-[18px]' + : 'py-2.5', + ) + const content = ( + <> + {/* Plain template string: the category hover color is a `text-*` utility + and twMerge would drop it against a base color. */} + + + {library.name} + + + ) + + if (external) { + return ( + + {content} + + ) + } + + return ( + + {content} + + ) +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 4931d7a7d..eff3a1b39 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,5 +1,10 @@ import * as React from 'react' import { twMerge } from 'tailwind-merge' +const LazyLibrariesMenuContent = React.lazy(() => + import('./LibrariesMenuContent').then((module) => ({ + default: module.LibrariesMenuContent, + })), +) const LazyAiDock = React.lazy(() => import('./AiDock').then((m) => ({ default: m.AiDock })), ) @@ -42,15 +47,6 @@ import { AiDockButton, SearchButton } from './SearchButton' import { BrandContextMenu } from './BrandContextMenu' import { useSearchContext } from '~/contexts/SearchContext' import { useLibrariesOverlay } from '~/contexts/LibrariesOverlayContext' -import { publicLibraries, type LibrarySlim } from '~/libraries' -import { - categoryLabels, - categoryOrder, - categoryTextColor, - libraryCategories, - type LibraryCategory, -} from '~/libraries/categories' -import { fallbackLibraryIcon, libraryIcons } from '~/libraries/icons' import { GithubIcon } from '~/components/icons/GithubIcon' import { Dropdown, @@ -337,69 +333,6 @@ const NAV_GROUPS = [ }, ] as const satisfies readonly NavMenuGroup[] -function getLibraryDisplayName(library: LibrarySlim) { - return library.name.replace(/^TanStack\s+/, '') -} - -type LibraryMenuEntry = { - id: string - name: string - to: string - icon: IconComponent - /** `group-hover/lib:text-category-*` — recolors the icon to its category. */ - iconHoverColor: string -} - -// Full static class strings (Tailwind can't see composed names) mapping each -// category to the hover color applied to a library's icon in the mega-menu. -const categoryIconHoverColor: Record = { - framework: 'group-hover/lib:text-category-framework', - data: 'group-hover/lib:text-category-data', - ui: 'group-hover/lib:text-category-ui', - performance: 'group-hover/lib:text-category-performance', - tooling: 'group-hover/lib:text-category-tooling', -} - -type LibraryMenuColumn = { - category: LibraryCategory - label: string - colorClass: string - libraries: LibraryMenuEntry[] -} - -/** - * The Libraries mega-menu as five category columns (Framework, Data & State, - * UI & UX, Performance, Tooling), built from the canonical `libraryCategories` - * taxonomy. Iterating `libraryCategories` preserves the intended per-category - * order; only public, navigable libraries are shown. - */ -function getLibraryCategoryColumns(): LibraryMenuColumn[] { - const byCategory = new Map( - categoryOrder.map((category) => [category, []]), - ) - - for (const [id, category] of Object.entries(libraryCategories)) { - const library = publicLibraries.find((lib) => lib.id === id) - if (!library || !library.to) continue - byCategory.get(category)?.push({ - id: library.id, - name: getLibraryDisplayName(library), - to: library.to, - icon: libraryIcons[library.id] ?? fallbackLibraryIcon, - iconHoverColor: categoryIconHoverColor[category], - }) - } - - return categoryOrder - .map((category) => ({ - category, - label: categoryLabels[category], - colorClass: categoryTextColor[category], - libraries: byCategory.get(category) ?? [], - })) - .filter((column) => column.libraries.length > 0) -} - function AiDockMount() { const { isAiDockOpen } = useSearchContext() const [hasActivated, setHasActivated] = React.useState(isAiDockOpen) @@ -760,6 +693,7 @@ function DesktopNavTrigger({ onDismiss: () => void onResetDismissed: () => void }) { + const [hasLoadedLibraries, setHasLoadedLibraries] = React.useState(false) const { openLibraries } = useLibrariesOverlay() const triggerClassName = twMerge( 'ts-mega-trigger inline-flex items-center gap-1 rounded-md px-2 py-2 text-xs font-medium min-[1120px]:gap-1.5 min-[1120px]:px-3 min-[1120px]:text-[13px]', @@ -775,8 +709,14 @@ function DesktopNavTrigger({ onAuxClick={(event) => { if (event.button === 1) onDismiss() }} + onPointerEnter={() => { + if (group.key === 'libraries') setHasLoadedLibraries(true) + }} onPointerLeave={onResetDismissed} - onFocusCapture={onResetDismissed} + onFocusCapture={() => { + onResetDismissed() + if (group.key === 'libraries') setHasLoadedLibraries(true) + }} > {group.key === 'libraries' ? ( )} - + ) } @@ -820,9 +764,11 @@ function DesktopNavTrigger({ function DesktopNavDropdown({ group, onNavigate, + loadLibraries, }: { group: NavMenuGroup onNavigate: () => void + loadLibraries: boolean }) { return (
@@ -836,11 +782,15 @@ function DesktopNavDropdown({ 'min-[1120px]:px-[43px] min-[1120px]:pt-12 min-[1120px]:pb-[38px]', )} > - + {group.key !== 'libraries' || loadLibraries ? ( + + ) : ( + + )}
) @@ -988,6 +938,17 @@ function MobileNavigation({ ) } +function LibrariesMenuFallback() { + return ( +
+ Loading libraries… +
+ ) +} + function MegaMenuContent({ group, onNavigate, @@ -998,7 +959,11 @@ function MegaMenuContent({ variant: 'desktop' | 'mobile' }) { if (group.key === 'libraries') { - return + return ( + }> + + + ) } if (group.key === 'learn') { @@ -1080,188 +1045,6 @@ function MegaMenuContent({ ) } -function LibrariesMenuContent({ - onNavigate, - variant, -}: { - onNavigate: () => void - variant: 'desktop' | 'mobile' -}) { - const { openLibraries } = useLibrariesOverlay() - const columns = getLibraryCategoryColumns() - - const allLibraries = ( - - ) - - if (variant === 'mobile') { - return ( -
- {columns.map((column) => ( - - ))} - {allLibraries} -
- ) - } - - return ( -
-
- {columns.map((column) => ( - - ))} -
-
- {allLibraries} -
-
- ) -} - -function LibraryCategoryColumn({ - column, - onNavigate, - variant, -}: { - column: LibraryMenuColumn - onNavigate: () => void - variant: 'desktop' | 'mobile' -}) { - return ( -
-
- {column.label} -
-
- {column.libraries.map((library) => ( - - ))} -
-
- ) -} - -function LibraryMenuRow({ - library, - onNavigate, - variant, -}: { - library: LibraryMenuEntry - onNavigate: () => void - variant: 'desktop' | 'mobile' -}) { - const Icon = library.icon - const external = library.to.startsWith('http') - const className = twMerge( - // Light mode: an "elevated white" hover — a bright-white pill lifted off the - // glass with a soft shadow + hairline ring (contrast via depth, not value). - // Dark mode keeps the subtle white/4% (pressed 12%) overlay, no shadow/ring. - 'group/lib flex items-center gap-2 rounded-[14px] py-2 pl-[9px] pr-4 text-text-secondary transition-[color,background-color,box-shadow] hover:bg-white hover:text-text-primary hover:shadow-sm hover:ring-1 hover:ring-black/5 focus:bg-white focus:text-text-primary focus:shadow-sm focus:ring-1 focus:ring-black/5 focus:outline-none active:bg-white dark:hover:bg-text-primary/[0.04] dark:hover:shadow-none dark:hover:ring-0 dark:focus:bg-text-primary/[0.04] dark:focus:shadow-none dark:focus:ring-0 dark:active:bg-text-primary/[0.12]', - variant === 'desktop' - ? 'h-[38px] min-[1120px]:h-[46px] min-[1120px]:gap-2.5 min-[1120px]:rounded-[17px] min-[1120px]:pl-[11px] min-[1120px]:pr-[18px]' - : 'py-2.5', - ) - const content = ( - <> - {/* Plain template string: the category hover color is a `text-*` utility - and twMerge would drop it against a base color. */} - - - {library.name} - - - ) - - if (external) { - return ( - - {content} - - ) - } - - return ( - - {content} - - ) -} - function BlogMenuContent({ group, onNavigate, diff --git a/src/components/builder/BuilderProjectDraftPage.client.tsx b/src/components/builder/BuilderProjectDraftPage.client.tsx index 087aee187..10b76b4af 100644 --- a/src/components/builder/BuilderProjectDraftPage.client.tsx +++ b/src/components/builder/BuilderProjectDraftPage.client.tsx @@ -41,11 +41,10 @@ import { saveBuilderProjectDraft, } from '~/utils/builder-project-draft' import { createBuilderProject } from '~/utils/builder-project.client' -import { isBuilderProjectId } from '~/utils/builder-project' import { getBuilderProjectDraftPromotionIds, - promoteBuilderProjectTranscript, -} from '~/utils/builder-project-transcript-import.client' + isBuilderProjectId, +} from '~/utils/builder-project' type LocalSaveState = 'error' | 'saved' | 'saving' @@ -378,9 +377,12 @@ export function BuilderProjectDraftPage({ template }: { template?: string }) { setSaving(true) setSaveError('') persistDraft() + const projectToSave = currentProject() try { - const project = await createBuilderProject(currentProject(), { + const { promoteBuilderProjectTranscript } = + await import('~/utils/builder-project-transcript-import.client') + const project = await createBuilderProject(projectToSave, { clientMutationId: draftId, id: draftId, revisionId: promotionIds.revisionId, diff --git a/src/components/builder/BuilderProjectPage.client.tsx b/src/components/builder/BuilderProjectPage.client.tsx index 7bb38110b..a555eed9a 100644 --- a/src/components/builder/BuilderProjectPage.client.tsx +++ b/src/components/builder/BuilderProjectPage.client.tsx @@ -1,3 +1,4 @@ +import { getBuilderProjectTranscriptImportMutationId } from '~/utils/builder-project' import * as React from 'react' import { useLiveQuery } from '@tanstack/react-db' import { @@ -58,7 +59,6 @@ import { type BuilderProjectSyncRow, } from '~/utils/builder-project-sync.client' import { - getBuilderProjectTranscriptImportMutationId, importBuilderProjectTranscriptCommands, prepareBuilderProjectForkTranscriptImport, promoteBuilderProjectTranscript, diff --git a/src/components/examples/ExampleWorkbench.client.tsx b/src/components/examples/ExampleWorkbench.client.tsx index 2d0054cf5..3e6d4ac0d 100644 --- a/src/components/examples/ExampleWorkbench.client.tsx +++ b/src/components/examples/ExampleWorkbench.client.tsx @@ -87,7 +87,7 @@ import { type ExampleDefinition, type ExampleWorkspace, } from '~/utils/example-workspace' -import { CodeMirrorEditor } from './CodeMirrorEditor.client' +import { LazyCodeMirrorEditor } from './LazyCodeMirrorEditor.client' import { MAX_SANDBOX_BROWSER_ANNOTATION_PROMPT_LENGTH, SandboxBrowser, @@ -3124,7 +3124,7 @@ export function ExampleWorkbench({ ))}
-
- + import('./CodeMirrorEditor.client').then((module) => ({ + default: module.CodeMirrorEditor, + })), +) + +export function LazyCodeMirrorEditor( + props: React.ComponentProps, +) { + const containerRef = React.useRef(null) + const inView = useInView(containerRef) + const [hasActivated, setHasActivated] = React.useState(false) + + React.useEffect(() => { + if (inView) setHasActivated(true) + }, [inView]) + + return ( +
+ {hasActivated || inView ? ( + + Loading editor… +
+ } + > + + + ) : null} +
+ ) +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index c8711dc48..a18f6d2ca 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,3 +1,7 @@ +import { + GOOGLE_ANALYTICS_BOOTSTRAP, + GOOGLE_ANALYTICS_HYDRATED_EVENT, +} from '~/utils/analytics/bootstrap' import * as React from 'react' import { createRootRouteWithContext, @@ -48,11 +52,8 @@ import { trackPageView } from '~/utils/analytics' import { createPartnerPlacementSessionSeed } from '~/utils/partner-placement' import { twMerge } from 'tailwind-merge' -const GOOGLE_ANALYTICS_ID = 'G-JMT1Z50SPS' -const GOOGLE_ANALYTICS_PROXY_PREFIX = '/_a' -const GOOGLE_ANALYTICS_SCRIPT_SRC = `${GOOGLE_ANALYTICS_PROXY_PREFIX}/gtag.js` const THEME_BOOTSTRAP = `(function(){try{var t=localStorage.getItem('theme')||'auto';var v=['light','dark','auto'].includes(t)?t:'auto';var r=v==='auto'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):v;if(document.documentElement){document.documentElement.classList.add(r);if(v==='auto')document.documentElement.classList.add('auto');document.documentElement.style.colorScheme=r}}catch(e){if(document.documentElement){var r=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';document.documentElement.classList.add(r,'auto');document.documentElement.style.colorScheme=r}}})()` -const GOOGLE_ANALYTICS_BOOTSTRAP = `(function(){var id='${GOOGLE_ANALYTICS_ID}';var src='${GOOGLE_ANALYTICS_SCRIPT_SRC}';window.dataLayer=window.dataLayer||[];window.gtag=window.gtag||function(){window.dataLayer.push(arguments)};window.gtag('js',new Date());window.gtag('config',id,{transport_url:window.location.origin+'${GOOGLE_ANALYTICS_PROXY_PREFIX}'});var loaded=false;var load=function(){if(loaded)return;var parent=document.head||document.documentElement;if(!parent){window.setTimeout(load,100);return}loaded=true;var script=document.createElement('script');script.async=true;script.src=src;script.setAttribute('data-ga-loader','true');parent.appendChild(script)};if(typeof window.requestIdleCallback==='function'){window.requestIdleCallback(load,{timeout:3000});return}if(document.readyState==='complete'){window.setTimeout(load,1500);return}window.addEventListener('load',function(){window.setTimeout(load,1500)},{once:true})})();` + const DOCUMENT_CACHE_HEADERS = { 'Cache-Control': 'public, max-age=0, must-revalidate', 'Cloudflare-CDN-Cache-Control': 'no-store', @@ -401,6 +402,10 @@ function ShellComponent({ children }: { children: React.ReactNode }) { } function PageViewTracker() { + React.useEffect(() => { + window.dispatchEvent(new Event(GOOGLE_ANALYTICS_HYDRATED_EVENT)) + }, []) + const pagePath = useRouterState({ select: (s) => { const pathname = s.resolvedLocation?.pathname || '/' diff --git a/src/utils/analytics/bootstrap.ts b/src/utils/analytics/bootstrap.ts new file mode 100644 index 000000000..e34f28b91 --- /dev/null +++ b/src/utils/analytics/bootstrap.ts @@ -0,0 +1,7 @@ +const GOOGLE_ANALYTICS_ID = 'G-JMT1Z50SPS' +const GOOGLE_ANALYTICS_PROXY_PREFIX = '/_a' +export const GOOGLE_ANALYTICS_HYDRATED_EVENT = 'tanstack:analytics-ready' + +// Queue the initial URL before SPA navigation can change it. Load the provider +// only once the app has hydrated, when an idle CPU also means startup has run. +export const GOOGLE_ANALYTICS_BOOTSTRAP = `(function(){if(window.__tanstackAnalyticsBootstrapped)return;window.__tanstackAnalyticsBootstrapped=true;var id='${GOOGLE_ANALYTICS_ID}';window.dataLayer=window.dataLayer||[];window.gtag=window.gtag||function(){window.dataLayer.push(arguments)};window.gtag('js',new Date());window.gtag('config',id,{transport_url:window.location.origin+'${GOOGLE_ANALYTICS_PROXY_PREFIX}',send_page_view:false});window.gtag('event','page_view',{page_location:window.location.href.split('#')[0],page_title:document.title});var loaded=false;var load=function(){if(loaded)return;var parent=document.head||document.documentElement;if(!parent){window.setTimeout(load,100);return}loaded=true;var script=document.createElement('script');script.async=true;script.src='${GOOGLE_ANALYTICS_PROXY_PREFIX}/gtag.js';script.setAttribute('data-ga-loader','true');parent.appendChild(script)};window.addEventListener('${GOOGLE_ANALYTICS_HYDRATED_EVENT}',function(){if(typeof window.requestIdleCallback==='function'){window.requestIdleCallback(load,{timeout:3000});return}window.setTimeout(load,1500)},{once:true})})();` diff --git a/src/utils/builder-project-transcript-import.client.ts b/src/utils/builder-project-transcript-import.client.ts index 8659a2ea9..67f35c9a7 100644 --- a/src/utils/builder-project-transcript-import.client.ts +++ b/src/utils/builder-project-transcript-import.client.ts @@ -455,25 +455,6 @@ export async function importBuilderProjectTranscriptCommands({ ) } -export function getBuilderProjectDraftPromotionIds(draftId: string) { - if (!isBuilderProjectId(draftId)) { - throw new Error('Invalid Builder project draft ID') - } - - return { - revisionId: derivePromotionId(draftId, 1), - transcriptImportMutationId: - getBuilderProjectTranscriptImportMutationId(draftId), - } -} - -export function getBuilderProjectTranscriptImportMutationId(projectId: string) { - if (!isBuilderProjectId(projectId)) { - throw new Error('Invalid Builder project ID') - } - return derivePromotionId(projectId, 2) -} - async function chunkTranscriptImport({ clientMutationId, threads, @@ -704,11 +685,6 @@ function assertDistinctTranscriptImportIds( } } -function derivePromotionId(draftId: string, discriminator: number) { - const firstNibble = Number.parseInt(draftId[0] ?? '', 16) - return `${(firstNibble ^ discriminator).toString(16)}${draftId.slice(1)}` -} - function getQueuedTranscriptImportCommand( command: BuilderProjectSyncCommand, clientMutationId: string, diff --git a/src/utils/builder-project.ts b/src/utils/builder-project.ts index a752c50bf..13f369ccb 100644 --- a/src/utils/builder-project.ts +++ b/src/utils/builder-project.ts @@ -41,6 +41,30 @@ export function isBuilderProjectId(value: string) { return builderProjectIdPattern.test(value) } +export function getBuilderProjectDraftPromotionIds(draftId: string) { + if (!isBuilderProjectId(draftId)) { + throw new Error('Invalid Builder project draft ID') + } + + return { + revisionId: derivePromotionId(draftId, 1), + transcriptImportMutationId: + getBuilderProjectTranscriptImportMutationId(draftId), + } +} + +export function getBuilderProjectTranscriptImportMutationId(projectId: string) { + if (!isBuilderProjectId(projectId)) { + throw new Error('Invalid Builder project ID') + } + return derivePromotionId(projectId, 2) +} + +function derivePromotionId(draftId: string, discriminator: number) { + const firstNibble = Number.parseInt(draftId[0] ?? '', 16) + return `${(firstNibble ^ discriminator).toString(16)}${draftId.slice(1)}` +} + export function isBuilderProjectTimestamp(value: string) { const date = new Date(value) return !Number.isNaN(date.getTime()) && date.toISOString() === value diff --git a/tests/analytics-bootstrap.test.ts b/tests/analytics-bootstrap.test.ts new file mode 100644 index 000000000..3d833b6cf --- /dev/null +++ b/tests/analytics-bootstrap.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createContext, runInContext } from 'node:vm' +import { + GOOGLE_ANALYTICS_BOOTSTRAP, + GOOGLE_ANALYTICS_HYDRATED_EVENT, +} from '../src/utils/analytics/bootstrap' + +function setup(idleSupported = true) { + const events = new EventTarget() + const idleCallbacks: Array<() => void> = [] + const timers: Array<{ callback: () => void; delay: number }> = [] + function createScript() { + const attributes = new Map() + return { + async: false, + src: '', + attributes, + setAttribute(name: string, value: string) { + attributes.set(name, value) + }, + } + } + const scripts: Array> = [] + const window = { + location: new URL('https://tanstack.com/query/latest#example'), + addEventListener: events.addEventListener.bind(events), + setTimeout(callback: () => void, delay: number) { + timers.push({ callback, delay }) + }, + ...(idleSupported + ? { + requestIdleCallback( + callback: () => void, + options: { timeout: number }, + ) { + assert.equal(options.timeout, 3000) + idleCallbacks.push(callback) + }, + } + : {}), + } + const context = createContext({ + window, + document: { + title: 'TanStack Query', + head: { + appendChild(script: ReturnType) { + scripts.push(script) + }, + }, + createElement: createScript, + }, + }) + runInContext(GOOGLE_ANALYTICS_BOOTSTRAP, context) + return { + scripts, + idleCallbacks, + timers, + window, + context, + hydrate() { + events.dispatchEvent(new Event(GOOGLE_ANALYTICS_HYDRATED_EVENT)) + }, + queue() { + return JSON.parse( + runInContext( + 'JSON.stringify(window.dataLayer.map(function(event){return Array.from(event)}))', + context, + ), + ) + }, + } +} + +test('analytics queues the initial page and SPA events before loading after hydration', () => { + const state = setup() + runInContext(GOOGLE_ANALYTICS_BOOTSTRAP, state.context) + assert.equal(state.scripts.length, 0) + assert.equal(state.idleCallbacks.length, 0) + assert.equal(state.timers.length, 0) + state.window.location.href = 'https://tanstack.com/builder' + runInContext( + "window.gtag('event', 'page_view', {page_location: window.location.href})", + state.context, + ) + assert.deepEqual(state.queue()[1], [ + 'config', + 'G-JMT1Z50SPS', + { + transport_url: 'https://tanstack.com/_a', + send_page_view: false, + }, + ]) + assert.deepEqual(state.queue()[2], [ + 'event', + 'page_view', + { + page_location: 'https://tanstack.com/query/latest', + page_title: 'TanStack Query', + }, + ]) + assert.deepEqual(state.queue()[3], [ + 'event', + 'page_view', + { page_location: 'https://tanstack.com/builder' }, + ]) + + state.hydrate() + state.hydrate() + assert.equal(state.idleCallbacks.length, 1) + assert.equal(state.scripts.length, 0) + state.idleCallbacks[0]() + state.idleCallbacks[0]() + assert.equal(state.scripts.length, 1) + assert.equal(state.scripts[0].src, '/_a/gtag.js') + assert.equal(state.scripts[0].async, true) + assert.equal(state.scripts[0].attributes.get('data-ga-loader'), 'true') + assert.equal(state.queue().length, 4) +}) + +test('analytics waits for hydration before its timer fallback', () => { + const state = setup(false) + assert.equal(state.timers.length, 0) + state.hydrate() + state.hydrate() + assert.equal(state.timers.length, 1) + assert.equal(state.timers[0].delay, 1500) + state.timers[0].callback() + assert.equal(state.scripts.length, 1) +}) diff --git a/tests/builder-project-transcript-import.test.ts b/tests/builder-project-transcript-import.test.ts index 32f01fef5..f96de2068 100644 --- a/tests/builder-project-transcript-import.test.ts +++ b/tests/builder-project-transcript-import.test.ts @@ -1,3 +1,4 @@ +import { getBuilderProjectDraftPromotionIds } from '../src/utils/builder-project' import assert from 'node:assert/strict' import test from 'node:test' import { @@ -10,7 +11,6 @@ import { import { createBuilderProjectForkTranscriptImportCommands, createBuilderProjectTranscriptImportCommands, - getBuilderProjectDraftPromotionIds, importBuilderProjectTranscriptCommands, prepareBuilderProjectForkTranscriptImport, promoteBuilderProjectTranscript,