diff --git a/webui/fixture/stats-tabs-50.jsonl b/webui/fixture/atlas-tabs-50.jsonl similarity index 100% rename from webui/fixture/stats-tabs-50.jsonl rename to webui/fixture/atlas-tabs-50.jsonl diff --git a/webui/metro.config.js b/webui/metro.config.js index 90350b0..4900a27 100644 --- a/webui/metro.config.js +++ b/webui/metro.config.js @@ -18,7 +18,7 @@ config.cacheStores = [ // Initialize the Expo Atlas global data source in development if (process.env.NODE_ENV === 'development') { const { StatsFileSource } = require('../build/src/data/StatsFileSource'); - const statsFile = path.resolve(__dirname, './fixture/stats-tabs-50.jsonl'); + const statsFile = path.resolve(__dirname, './fixture/atlas-tabs-50.jsonl'); global.EXPO_ATLAS_SOURCE = new StatsFileSource(statsFile); } diff --git a/webui/src/app/_layout.tsx b/webui/src/app/_layout.tsx index 1f98f7a..b4789e7 100644 --- a/webui/src/app/_layout.tsx +++ b/webui/src/app/_layout.tsx @@ -1,6 +1,5 @@ import { Slot } from 'expo-router'; -import { ModuleFilterProvider } from '~/providers/modules'; import { QueryProvider } from '~/providers/query'; import { StatsEntryProvider } from '~/providers/stats'; import { ThemeProvider } from '~/providers/theme'; @@ -34,9 +33,7 @@ export default function RootLayout() { - - - + diff --git a/webui/src/app/api/stats/[entry]/modules/index+api.ts b/webui/src/app/api/stats/[entry]/modules/index+api.ts index 5e9cee1..bfbab70 100644 --- a/webui/src/app/api/stats/[entry]/modules/index+api.ts +++ b/webui/src/app/api/stats/[entry]/modules/index+api.ts @@ -1,4 +1,4 @@ -import { filtersFromUrlParams } from '~/providers/modules'; +import { statsModuleFiltersFromUrlParams } from '~/components/forms/StatsModuleFilter'; import { getSource } from '~/utils/atlas'; import { type StatsEntry, type StatsModule } from '~core/data/types'; import { fuzzyFilterModules } from '~core/utils/search'; @@ -55,14 +55,14 @@ export async function GET(request: Request, params: Record<'entry', string>) { * - `exclude=` to only exclude specific glob patterns */ function filterModules(request: Request, stats: StatsEntry): ModuleMetadata[] { - const { types, ...patterns } = filtersFromUrlParams(new URL(request.url).searchParams); + const filters = statsModuleFiltersFromUrlParams(new URL(request.url).searchParams); let modules = Array.from(stats.modules.values()); - if (!types.includes('node_modules')) { + if (!filters.modules.includes('node_modules')) { modules = modules.filter((module) => !module.package); } - return fuzzyFilterModules(modules, patterns).map((module) => ({ + return fuzzyFilterModules(modules, filters).map((module) => ({ ...module, source: undefined, output: undefined, diff --git a/webui/src/app/stats/[entry]/index.tsx b/webui/src/app/stats/[entry]/index.tsx index c811128..047a39f 100644 --- a/webui/src/app/stats/[entry]/index.tsx +++ b/webui/src/app/stats/[entry]/index.tsx @@ -2,13 +2,13 @@ import { useQuery } from '@tanstack/react-query'; import type { EntryGraphData } from '~/app/api/stats/[entry]/modules/index+api'; import { Page, PageHeader, PageTitle } from '~/components/Page'; -import { StatsModuleFilter } from '~/components/forms/StatsModuleFilter'; -import { TreemapGraph } from '~/components/graphs/TreemapGraph'; import { type ModuleFilters, - useModuleFilterContext, - filtersToUrlParams, -} from '~/providers/modules'; + StatsModuleFilter, + statsModuleFiltersToUrlParams, + useStatsModuleFilters, +} from '~/components/forms/StatsModuleFilter'; +import { TreemapGraph } from '~/components/graphs/TreemapGraph'; import { useStatsEntry } from '~/providers/stats'; import { Tag } from '~/ui/Tag'; import { fetchApi } from '~/utils/api'; @@ -16,7 +16,7 @@ import { formatFileSize } from '~/utils/formatString'; export default function StatsScreen() { const { entry } = useStatsEntry(); - const { filters } = useModuleFilterContext(); + const filters = useStatsModuleFilters(); const graph = useBundleGraphData(entry.id, filters); @@ -58,13 +58,13 @@ function BundleSummary({ data }: { data: EntryGraphData }) { } /** Load the bundle graph data from API, with default or custom filters */ -function useBundleGraphData(entryId: string, filters?: ModuleFilters) { +function useBundleGraphData(entryId: string, filters: ModuleFilters) { return useQuery({ queryKey: [`bundle-graph`, entryId, filters], queryFn: ({ queryKey }) => { const [_key, entry, filters] = queryKey as [string, string, ModuleFilters | undefined]; const url = filters - ? `/api/stats/${entry}/modules?${filtersToUrlParams(filters)}` + ? `/api/stats/${entry}/modules?${statsModuleFiltersToUrlParams(filters)}` : `/api/stats/${entry}/modules`; return fetchApi(url) diff --git a/webui/src/components/forms/StatsModuleFilter.tsx b/webui/src/components/forms/StatsModuleFilter.tsx index a827db0..d88e468 100644 --- a/webui/src/components/forms/StatsModuleFilter.tsx +++ b/webui/src/components/forms/StatsModuleFilter.tsx @@ -1,6 +1,6 @@ -import { type FormEvent, type KeyboardEvent, useState } from 'react'; +import { useGlobalSearchParams, useRouter } from 'expo-router'; +import { type FormEvent, type KeyboardEvent, useState, useCallback } from 'react'; -import { useModuleFilterContext, useModuleFilterReducer } from '~/providers/modules'; import { Button } from '~/ui/Button'; import { Checkbox } from '~/ui/Checkbox'; import { Input } from '~/ui/Input'; @@ -13,40 +13,67 @@ import { SheetTitle, SheetTrigger, } from '~/ui/Sheet'; +import { debounce } from '~/utils/debounce'; + +export type ModuleFilters = typeof DEFAULT_FILTERS; + +const DEFAULT_FILTERS = { + modules: 'project,node_modules', + include: '', + exclude: '', +}; + +export function useStatsModuleFilters(): ModuleFilters { + const filters = useGlobalSearchParams>(); + return { + modules: filters.modules || DEFAULT_FILTERS.modules, + include: filters.include || DEFAULT_FILTERS.include, + exclude: filters.exclude || DEFAULT_FILTERS.exclude, + }; +} export function StatsModuleFilter() { - const { filters, setFilters } = useModuleFilterContext(); - // NOTE(cedric): keep a duplicate data store to avoid spamming the API with every change - const [formData, setFormData] = useModuleFilterReducer(filters); + const router = useRouter(); + const filters = useStatsModuleFilters(); + // NOTE(cedric): we want to programmatically close the dialog when the form is submitted, so make it controlled const [dialogOpen, setDialogOpen] = useState(false); function onFormSubmit(event: FormEvent) { event.preventDefault(); event.stopPropagation(); - setDialogOpen(false); - setFilters(formData); } - function onInputEnter( - event: KeyboardEvent, - data: (data: typeof formData) => typeof formData - ) { + function onInputEnter(event: KeyboardEvent) { if (event.key === 'Enter') { event.preventDefault(); setDialogOpen(false); - setFilters(data(formData)); } } - function onDialogChange(isOpen: boolean) { - setDialogOpen(isOpen); - if (!isOpen) setFormData(filters); - } + const onModuleChange = useCallback((includeNodeModules: boolean) => { + router.setParams({ + modules: includeNodeModules ? undefined : 'project', + }); + }, []); + + const onIncludeChange = useCallback( + debounce((value: string) => { + router.setParams({ include: value || undefined }); + }, 300), + [] + ); + + const onExcludeChange = useCallback( + debounce((value: string) => { + router.setParams({ include: value || undefined }); + }, 300), + [] + ); return ( - + @@ -65,13 +92,9 @@ export function StatsModuleFilter() { { - setFormData( - isChecked ? { types: ['project', 'node_modules'] } : { types: ['project'] } - ); - }} + defaultChecked={filters.modules.includes('node_modules')} + name="filterNodeModules" + onCheckedChange={onModuleChange} /> @@ -85,11 +108,9 @@ export function StatsModuleFilter() { type="text" className="mt-2" placeholder="e.g. app/**/*.{ts}" - value={formData.include} - onChange={(event) => setFormData({ include: event.currentTarget.value })} - onKeyDown={(event) => - onInputEnter(event, (data) => ({ ...data, include: event.currentTarget.value })) - } + defaultValue={filters.include} + onChange={(event) => onIncludeChange(event.currentTarget.value)} + onKeyDown={onInputEnter} /> @@ -103,24 +124,30 @@ export function StatsModuleFilter() { type="text" className="mt-2" placeholder="e.g. react-native/**" - value={formData.exclude} - onChange={(event) => setFormData({ exclude: event.currentTarget.value })} - onKeyDown={(event) => - onInputEnter(event, (data) => ({ ...data, exclude: event.currentTarget.value })) - } + defaultValue={filters.exclude} + onChange={(event) => onExcludeChange(event.currentTarget.value)} + onKeyDown={onInputEnter} /> - -
- - -
); } + +export function statsModuleFiltersToUrlParams(filters: ModuleFilters) { + const params = new URLSearchParams({ modules: filters.modules }); + + if (filters.include) params.set('include', filters.include); + if (filters.exclude) params.set('exclude', filters.exclude); + + return params.toString(); +} + +export function statsModuleFiltersFromUrlParams(params: URLSearchParams): ModuleFilters { + return { + modules: params.get('modules') || DEFAULT_FILTERS.modules, + include: params.get('include') || DEFAULT_FILTERS.include, + exclude: params.get('exclude') || DEFAULT_FILTERS.exclude, + }; +} diff --git a/webui/src/providers/modules.tsx b/webui/src/providers/modules.tsx deleted file mode 100644 index 362515c..0000000 --- a/webui/src/providers/modules.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { type PropsWithChildren, createContext, useContext, useReducer } from 'react'; - -export type ModuleFilters = { - /** The types of modules to show */ - types: ('project' | 'node_modules')[]; - /** An optional inclusion glob pattern */ - include?: string; - /** An optional exclusion glob pattern */ - exclude?: string; -}; - -const DEFAULT_FILTERS: ModuleFilters = { - types: ['project', 'node_modules'], -}; - -type ModuleFilterContext = { - filters: ModuleFilters; - setFilters: (action: Partial) => void; -}; - -export const moduleFilterContext = createContext({ - filters: DEFAULT_FILTERS, - setFilters: () => {}, -}); - -export function ModuleFilterProvider({ children }: PropsWithChildren) { - const [filters, setFilters] = useModuleFilterReducer(); - - return ( - - {children} - - ); -} - -export const useModuleFilterContext = () => useContext(moduleFilterContext); -export const useModuleFilterReducer = (filters = DEFAULT_FILTERS) => - useReducer(moduleFilterReducer, filters); - -/** Keep track of the filters and update non-undefined properties */ -export function moduleFilterReducer( - state: ModuleFilters, - action: Partial -): ModuleFilters { - const nextState = { ...state }; - - if (action.types !== undefined) nextState.types = action.types; - if (action.include !== undefined) nextState.include = action.include; - if (action.exclude !== undefined) nextState.exclude = action.exclude; - - return nextState; -} - -/** Convert the current filters to query parameters */ -export function filtersToUrlParams(state: ModuleFilters) { - const params = new URLSearchParams({ - modules: state.types.join(','), - }); - - if (state.include) params.set('include', state.include); - if (state.exclude) params.set('exclude', state.exclude); - - return params.toString(); -} - -/** Convert the query paramters to filters */ -export function filtersFromUrlParams(params: URLSearchParams) { - const filters = { ...DEFAULT_FILTERS }; - - const modulesParam = params.get('modules'); - const includeParam = params.get('include'); - const excludeParam = params.get('exclude'); - - if (modulesParam) filters.types = modulesParam.split(',') as ModuleFilters['types']; - if (includeParam) filters.include = includeParam; - if (excludeParam) filters.exclude = excludeParam; - - return filters; -} diff --git a/webui/src/utils/debounce.ts b/webui/src/utils/debounce.ts new file mode 100644 index 0000000..9a1f6b4 --- /dev/null +++ b/webui/src/utils/debounce.ts @@ -0,0 +1,31 @@ +type Debounced any> = { + /** The debounced function, without any return value */ + (...args: Parameters): void; + /** Cancel the debounced timer and prevent the function being called */ + cancel(): void; +}; + +/** + * Create a function that which is called after some delay. + * The delay timer is reset every time the function is called. + * It's possible to fully cancel the function by using `debounce(...).cancel()`. + */ +export function debounce any>(action: T, delay = 500): Debounced { + let timerId: ReturnType | null = null; + + function cancel() { + if (timerId) { + clearTimeout(timerId); + timerId = null; + } + } + + const debounced = (...args: any[]) => { + if (timerId) clearTimeout(timerId); + timerId = setTimeout(() => action(...args), delay); + }; + + debounced.cancel = cancel; + + return debounced; +}