diff --git a/client/components/AutocompleteInput/AutocompleteInput.tsx b/client/components/AutocompleteInput/AutocompleteInput.tsx index 7c755df8..b8ddad26 100644 --- a/client/components/AutocompleteInput/AutocompleteInput.tsx +++ b/client/components/AutocompleteInput/AutocompleteInput.tsx @@ -78,6 +78,7 @@ export const AutocompleteInput = ({
{items as any}
@@ -112,7 +113,11 @@ export const AutocompleteInput = ({ )} - diff --git a/client/components/AutocompleteInput/components/SuggestionItem.tsx b/client/components/AutocompleteInput/components/SuggestionItem.tsx index fa902abc..5ba7e30f 100644 --- a/client/components/AutocompleteInput/components/SuggestionItem.tsx +++ b/client/components/AutocompleteInput/components/SuggestionItem.tsx @@ -18,6 +18,8 @@ export function SuggestionItem({ item, isHighlighted }: SuggestionItemProps) { className={cx('autocomplete-input__suggestion', { 'autocomplete-input__suggestion--highlight': isHighlighted, })} + role="option" + aria-selected={isHighlighted} > {item.highlight != null ? (
diff --git a/client/components/BuildProgressIndicator/BuildProgressIndicator.tsx b/client/components/BuildProgressIndicator/BuildProgressIndicator.tsx index 7e34432a..eff43164 100644 --- a/client/components/BuildProgressIndicator/BuildProgressIndicator.tsx +++ b/client/components/BuildProgressIndicator/BuildProgressIndicator.tsx @@ -32,7 +32,7 @@ export default class BuildProgressIndicator extends Component< } componentDidMount() { - setTimeout(() => { + this.timeoutId = setTimeout(() => { if (!this.props.isDone) { this.setState({ started: true }) this.setMessage() @@ -40,8 +40,8 @@ export default class BuildProgressIndicator extends Component< }, OptimisticLoadTimeout) } - componentWillReceiveProps(nextProps: BuildProgressIndicatorProps) { - if (nextProps.isDone) { + componentDidUpdate(prevProps: BuildProgressIndicatorProps) { + if (!prevProps.isDone && this.props.isDone) { this.stage = 3 this.props.onDone() } @@ -58,7 +58,7 @@ export default class BuildProgressIndicator extends Component< clearTimeout(this.timeoutId) } - getProgressText = (stage: typeof order[number]) => { + getProgressText = (stage: (typeof order)[number]) => { const progressText = { resolving: 'Resolving version and dependencies', building: 'Bundling package', diff --git a/client/components/PageNav/PageNav.scss b/client/components/PageNav/PageNav.scss index 98f4f6d1..1188a906 100644 --- a/client/components/PageNav/PageNav.scss +++ b/client/components/PageNav/PageNav.scss @@ -9,6 +9,8 @@ @media screen and (max-width: 40em) { padding: $global-spacing * 2; + flex-wrap: wrap; + row-gap: $global-spacing; } } @@ -17,6 +19,12 @@ display: flex; align-items: center; gap: 12px; + + @media screen and (max-width: 40em) { + flex: 1 1 100%; + justify-content: flex-end; + margin-left: 0; + } } .logo-small { @@ -61,7 +69,7 @@ max-width: 40vw; overflow: scroll; align-items: center; - justify-content: flex-end; + justify-content: flex-start; } } diff --git a/client/components/QuickStatsBar/QuickStatsBar.tsx b/client/components/QuickStatsBar/QuickStatsBar.tsx index d0e45f78..c3cea1b0 100644 --- a/client/components/QuickStatsBar/QuickStatsBar.tsx +++ b/client/components/QuickStatsBar/QuickStatsBar.tsx @@ -109,6 +109,7 @@ class QuickStatsBar extends Component { href={'https://npmjs.com/package/' + name} target="_blank" rel="noopener noreferrer" + aria-label={`View ${name} on npm`} > @@ -118,6 +119,7 @@ class QuickStatsBar extends Component { href={repository} target="_blank" rel="noopener noreferrer" + aria-label={`View ${name} repository`} > diff --git a/client/components/ResultLayout/ResultLayout.scss b/client/components/ResultLayout/ResultLayout.scss index 6e8ccffe..48910283 100644 --- a/client/components/ResultLayout/ResultLayout.scss +++ b/client/components/ResultLayout/ResultLayout.scss @@ -102,8 +102,7 @@ max-width: 40vw; overflow: scroll; align-items: center; - justify-content: flex-end; - overflow: scroll; + justify-content: flex-start; } } diff --git a/client/components/SimilarPackageCard/SimilarPackageCard.tsx b/client/components/SimilarPackageCard/SimilarPackageCard.tsx index bdee70d3..a70be22d 100644 --- a/client/components/SimilarPackageCard/SimilarPackageCard.tsx +++ b/client/components/SimilarPackageCard/SimilarPackageCard.tsx @@ -125,6 +125,7 @@ export default class SimilarPackageCard extends Component {pack.repository.includes('github.com') ? ( diff --git a/pages/package/[...packageString]/ResultPage.tsx b/pages/package/[...packageString]/ResultPage.tsx index ea1e9bc9..2affd4ed 100644 --- a/pages/package/[...packageString]/ResultPage.tsx +++ b/pages/package/[...packageString]/ResultPage.tsx @@ -21,7 +21,10 @@ import QuickStatsBar from '../../../client/components/QuickStatsBar/QuickStatsBa import ResultLayout from '../../../client/components/ResultLayout' import Stat from '../../../client/components/Stat' import Warning from '../../../client/components/Warning/Warning' -import { parsePackageString } from '../../../utils/common.utils' +import { + parsePackageString, + sanitizeErrorHTML, +} from '../../../utils/common.utils' import { DownloadSpeed, formatSize, @@ -103,6 +106,10 @@ class ResultPage extends PureComponent { } private activeQuery: string | null = null + private searchRequestId = 0 + + private isActiveSearch = (requestId: number) => + requestId === this.searchRequestId componentDidMount() { Analytics.pageView('package result') @@ -135,14 +142,14 @@ class ResultPage extends PureComponent { } } - fetchResults = (packageString: string) => { + fetchResults = (packageString: string, requestId: number) => { const startTime = Date.now() API.getInfo(packageString) .then(results => { - this.fetchSimilarPackages(packageString) + if (!this.isActiveSearch(requestId)) return - if (this.activeQuery !== packageString) return + this.fetchSimilarPackages(packageString, requestId) const newPackageString = `${results.name}@${results.version}` this.setState( @@ -162,6 +169,8 @@ class ResultPage extends PureComponent { }) }) .catch(err => { + if (!this.isActiveSearch(requestId)) return + this.setState({ resultsError: err, resultsPromiseState: 'rejected', @@ -175,10 +184,10 @@ class ResultPage extends PureComponent { }) } - fetchHistory = (packageString: string) => { + fetchHistory = (packageString: string, requestId: number) => { API.getHistory(packageString, 15) .then(results => { - if (this.activeQuery !== packageString) return + if (!this.isActiveSearch(requestId)) return this.setState({ historicalResultsPromiseState: 'fulfilled', @@ -186,16 +195,20 @@ class ResultPage extends PureComponent { }) }) .catch(err => { + if (!this.isActiveSearch(requestId)) return + this.setState({ historicalResultsPromiseState: 'rejected' }) console.error('Fetching history failed:', err) }) } - fetchSimilarPackages = (packageString: string) => { + fetchSimilarPackages = (packageString: string, requestId: number) => { const { name } = parsePackageString(packageString) API.getSimilar(name) .then(result => { + if (!this.isActiveSearch(requestId)) return + if (!result.category.label || result.category.score < 12) { return } @@ -205,7 +218,7 @@ class ResultPage extends PureComponent { ) Promise.allSettled(promises).then(results => { - if (this.activeQuery !== packageString) return + if (!this.isActiveSearch(requestId)) return this.setState({ similarPackagesCategory: result.category.label ?? '', @@ -221,6 +234,8 @@ class ResultPage extends PureComponent { }) }) .catch(err => { + if (!this.isActiveSearch(requestId)) return + this.setState({ historicalResultsPromiseState: 'rejected' }) console.error(err) }) @@ -229,6 +244,7 @@ class ResultPage extends PureComponent { handleSearchSubmit = (packageString: string) => { Analytics.performedSearch(packageString) const normalizedQuery = packageString.trim() + const requestId = ++this.searchRequestId this.setState( { @@ -242,11 +258,13 @@ class ResultPage extends PureComponent { similarPackagesCategory: '', }, () => { + if (!this.isActiveSearch(requestId)) return + this.activeQuery = normalizedQuery Router.push(`/package/${normalizedQuery}`) Analytics.pageView('package result') - this.fetchResults(normalizedQuery) - this.fetchHistory(normalizedQuery) + this.fetchResults(normalizedQuery, requestId) + this.fetchHistory(normalizedQuery, requestId) } ) } @@ -512,7 +530,9 @@ class ResultPage extends PureComponent {

{errorName}

{errorDetails && (

diff --git a/pages/package/[...packageString]/components/ExportAnalysisSection/ExportAnalysisSection.tsx b/pages/package/[...packageString]/components/ExportAnalysisSection/ExportAnalysisSection.tsx index cef8c80b..2cd1a1eb 100644 --- a/pages/package/[...packageString]/components/ExportAnalysisSection/ExportAnalysisSection.tsx +++ b/pages/package/[...packageString]/components/ExportAnalysisSection/ExportAnalysisSection.tsx @@ -11,6 +11,7 @@ import API, { import SearchIcon from '../../../../../client/components/Icons/SearchIcon' import JumpingDots from '../../../../../client/components/JumpingDots' import { formatSize, resolveBuildError } from '../../../../../utils' +import { sanitizeErrorHTML } from '../../../../../utils/common.utils' const State = { TBD: 'tbd', @@ -330,7 +331,11 @@ export default class ExportAnalysisSection extends Component< return (

{errorName}

-

+

{errorDetails &&

{errorDetails}
}
) diff --git a/pages/scan-results/ScanResults.scss b/pages/scan-results/ScanResults.scss index 68299ccf..d9615a47 100644 --- a/pages/scan-results/ScanResults.scss +++ b/pages/scan-results/ScanResults.scss @@ -221,3 +221,57 @@ $index-width: 4rem; text-decoration: line-through; } } + +@media screen and (max-width: 40em) { + .scan-results { + .page-content { + width: 100%; + padding: 0 $global-spacing * 2; + } + } + + .scan-results__sort-panel { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $global-spacing; + + button { + margin-left: 0; + } + } + + .scan-results__item { + display: grid; + grid-template-columns: 2.5rem minmax(0, 1fr); + column-gap: $global-spacing; + row-gap: $global-spacing; + } + + .scan-results__index { + width: auto; + } + + .scan-results__name { + width: auto; + min-width: 0; + padding-left: 0; + } + + .scan-results__stat-container { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: $global-spacing; + width: 100%; + max-width: none; + } + + .scan-results__item--total { + .scan-results__name { + grid-column: 1 / -1; + width: auto; + margin-left: 0; + } + } +} diff --git a/pages/scan-results/ScanResults.tsx b/pages/scan-results/ScanResults.tsx index 53a99cc1..23853908 100644 --- a/pages/scan-results/ScanResults.tsx +++ b/pages/scan-results/ScanResults.tsx @@ -10,7 +10,7 @@ import Analytics from '../../client/analytics' import API, { type PackageBuildInfo } from '../../client/api' import Stat from '../../client/components/Stat' import ResultLayout from '../../client/components/ResultLayout' -import { parsePackageString } from '../../utils/common.utils' +import { parsePackageString, sanitizeErrorHTML } from '../../utils/common.utils' import { getTimeFromSize } from '../../utils' type PromiseState = 'pending' | 'fulfilled' | 'rejected' @@ -131,7 +131,11 @@ class ResultCard extends Component { content = (
{pack.error.code} -

+

) break diff --git a/pages/scan/Scan.scss b/pages/scan/Scan.scss index 4d08fe39..be9b4888 100644 --- a/pages/scan/Scan.scss +++ b/pages/scan/Scan.scss @@ -36,6 +36,11 @@ background: var(--color-btn-bg-hover); } + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + & ~ & { margin-left: $global-spacing * 1.5; } @@ -105,3 +110,16 @@ } } } + +.scan__empty-selection { + @include font-size-sm; + color: var(--color-text-muted); + margin: $global-spacing * 2; +} + +.scan__unsupported-packages { + @include font-size-sm; + color: var(--color-text-muted); + margin: 0 $global-spacing * 2 $global-spacing * 2; + overflow-wrap: anywhere; +} diff --git a/pages/scan/Scan.tsx b/pages/scan/Scan.tsx index 56b177e7..d0818ebe 100644 --- a/pages/scan/Scan.tsx +++ b/pages/scan/Scan.tsx @@ -29,18 +29,96 @@ type SelectedPackage = { type ScanState = { packages: ScannablePackage[] | null selectedPackages: SelectedPackage[] + selectedPackageValues: string[] + unsupportedPackageNames: string[] } +type PersistedScanState = { + packages: ScannablePackage[] + selectedPackageValues: string[] + unsupportedPackageNames: string[] +} + +const persistedScanStateKey = 'bundlephobia.scan-state' + export default class Scan extends Component, ScanState> { state: ScanState = { packages: null, selectedPackages: [], + selectedPackageValues: [], + unsupportedPackageNames: [], } private packageSelectionContainerRef = createRef() componentDidMount() { Analytics.pageView('scan') + + const persistedScanState = this.readPersistedScanState() + if (persistedScanState) { + this.setState( + { + packages: persistedScanState.packages, + selectedPackageValues: persistedScanState.selectedPackageValues, + unsupportedPackageNames: persistedScanState.unsupportedPackageNames, + }, + this.setSelectedPackages + ) + } + } + + readPersistedScanState = (): PersistedScanState | null => { + try { + const serializedState = window.sessionStorage.getItem( + persistedScanStateKey + ) + if (!serializedState) { + return null + } + + const parsedState = JSON.parse(serializedState) as PersistedScanState + if (!Array.isArray(parsedState.packages)) { + return null + } + + return { + packages: parsedState.packages, + selectedPackageValues: Array.isArray(parsedState.selectedPackageValues) + ? parsedState.selectedPackageValues + : [], + unsupportedPackageNames: Array.isArray( + parsedState.unsupportedPackageNames + ) + ? parsedState.unsupportedPackageNames + : [], + } + } catch (error) { + console.error('Could not restore scan state:', error) + return null + } + } + + persistScanState = () => { + const { packages, selectedPackageValues } = this.state + + try { + if (!packages) { + window.sessionStorage.removeItem(persistedScanStateKey) + return + } + + const persistedState: PersistedScanState = { + packages, + selectedPackageValues, + unsupportedPackageNames: this.state.unsupportedPackageNames, + } + window.sessionStorage.setItem( + persistedScanStateKey, + JSON.stringify(persistedState) + ) + } catch (error) { + console.error('Could not persist scan state:', error) + } } resolveVersionFromRange = (range: string) => { @@ -59,7 +137,15 @@ export default class Scan extends Component, ScanState> { return { name, resolvedVersion } }) - this.setState({ selectedPackages }) + this.setState( + { + selectedPackages, + selectedPackageValues: Array.from(checkedInputs).map( + ({ value }) => value + ), + }, + this.persistScanState + ) } handleSelectionChange = () => { @@ -85,6 +171,15 @@ export default class Scan extends Component, ScanState> { }) } + getUnsupportedPackageNames(json: ParsedPackageJson): string[] { + const dependencies = json.dependencies ?? {} + + return Object.keys(dependencies).filter(packageName => { + const versionRange = dependencies[packageName] + return !semver.valid(versionRange) && !semver.validRange(versionRange) + }) + } + handleDropAccepted = ([file]: File[]) => { if (!file) { this.showInvalidFileError() @@ -102,8 +197,20 @@ export default class Scan extends Component, ScanState> { : '' const json = JSON.parse(result) as ParsedPackageJson const packages = this.getParsedPackages(json) + const unsupportedPackageNames = this.getUnsupportedPackageNames(json) - this.setState({ packages }, this.setSelectedPackages) + this.setState( + { + packages, + unsupportedPackageNames, + selectedPackageValues: packages + .filter( + ({ name }) => !scanBlacklist.some(regex => regex.test(name)) + ) + .map(({ name, resolvedVersion }) => `${name}#${resolvedVersion}`), + }, + this.setSelectedPackages + ) Analytics.scanPackageJsonDropped(packages.length) } catch (err) { console.error(err) @@ -125,6 +232,10 @@ export default class Scan extends Component, ScanState> { handleScanClick = () => { const { selectedPackages } = this.state + if (selectedPackages.length === 0) { + return + } + const query = selectedPackages .map(pack => `${pack.name}@${pack.resolvedVersion}`) .join(',') @@ -134,7 +245,15 @@ export default class Scan extends Component, ScanState> { } handleResetClick = () => { - this.setState({ packages: null, selectedPackages: [] }) + this.setState( + { + packages: null, + selectedPackages: [], + selectedPackageValues: [], + unsupportedPackageNames: [], + }, + this.persistScanState + ) } showInvalidFileError() { @@ -143,7 +262,12 @@ export default class Scan extends Component, ScanState> { } render() { - const { packages, selectedPackages } = this.state + const { + packages, + selectedPackages, + selectedPackageValues, + unsupportedPackageNames, + } = this.state let content: React.ReactNode if (!packages) { @@ -171,13 +295,27 @@ export default class Scan extends Component, ScanState> {

Select packages to scan

-
+ {unsupportedPackageNames.length > 0 && ( +

+ Skipped {unsupportedPackageNames.length}{' '} + {unsupportedPackageNames.length === 1 + ? 'dependency' + : 'dependencies'}{' '} + with unsupported version specifications:{' '} + {unsupportedPackageNames.join(', ')} +

+ )}
    , ScanState> {
+ {selectedPackages.length === 0 && ( +

+ Select at least one package to start a scan. +

+ )}
) } @@ -219,7 +362,3 @@ export default class Scan extends Component, ScanState> { ) } } - -export const getServerSideProps = () => { - return { props: {} } -} diff --git a/pages/scan/index.page.tsx b/pages/scan/index.page.tsx index a4733fb7..c821d57c 100644 --- a/pages/scan/index.page.tsx +++ b/pages/scan/index.page.tsx @@ -1,4 +1,3 @@ import Scan from './Scan' -export { getServerSideProps } from './Scan' export default Scan diff --git a/utils/common.utils.ts b/utils/common.utils.ts index f9f87096..b0bb306f 100644 --- a/utils/common.utils.ts +++ b/utils/common.utils.ts @@ -51,3 +51,12 @@ export function sanitizeHTML(html: string): string { ALLOWED_ATTR: [''], }) } + +/** Keep the API's small error-message formatting without trusting its data. */ +export function sanitizeErrorHTML(html: string): string { + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ['a', 'b', 'code', 'i'], + ALLOWED_ATTR: ['href', 'target', 'rel'], + ALLOW_DATA_ATTR: false, + }) +} diff --git a/yarn.lock b/yarn.lock index d483389f..dd164387 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16147,8 +16147,8 @@ __metadata: linkType: hard "koa@npm:^2.15.0": - version: 2.15.0 - resolution: "koa@npm:2.15.0::__archiveUrl=https%3A%2F%2Fregistry.npmjs.org%2Fkoa%2F-%2Fkoa-2.15.0.tgz" + version: 2.16.4 + resolution: "koa@npm:2.16.4" dependencies: accepts: "npm:^1.3.5" cache-content-type: "npm:^1.0.0" @@ -16173,7 +16173,7 @@ __metadata: statuses: "npm:^1.5.0" type-is: "npm:^1.6.16" vary: "npm:^1.1.2" - checksum: 018daa5d3521621699e4228de9191849083c0356e1e4abda6d96aa44fa3ee1f6a67849040c2a0b681697d1431a8232cca1e532a7246fc785257bfdf1e6ccf43a + checksum: bf21ffcf409bf847dca3f60349fc64a3b33e725e0ced3f405192a22fa51b4211cac29748f51df9674df82ca87691b3bdda64bbf80755ba6cdc6b2f35e878b0f5 languageName: node linkType: hard