Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace broken prop-types-exact package #15953

Merged
merged 7 commits into from
Aug 18, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 97 additions & 36 deletions packages/next/client/link.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import React, { Children } from 'react'
import { UrlObject } from 'url'
import {
PrefetchOptions,
NextRouter,
addBasePath,
isLocalURL,
NextRouter,
PrefetchOptions,
resolveHref,
} from '../next-server/lib/router/router'
import { execOnce } from '../next-server/lib/utils'
import { useRouter } from './router'
import { addBasePath, resolveHref } from '../next-server/lib/router/router'

type Url = string | UrlObject
type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K
}[keyof T]
type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never
}[keyof T]

export type LinkProps = {
href: Url
Expand All @@ -20,6 +26,8 @@ export type LinkProps = {
passHref?: boolean
prefetch?: boolean
}
type LinkPropsRequired = RequiredKeys<LinkProps>
type LinkPropsOptional = OptionalKeys<LinkProps>

let cachedObserver: IntersectionObserver
const listeners = new Map<Element, () => void>()
Expand Down Expand Up @@ -146,6 +154,91 @@ function linkClicked(

function Link(props: React.PropsWithChildren<LinkProps>) {
if (process.env.NODE_ENV !== 'production') {
function createPropError(args: {
key: string
expected: string
actual: string
}) {
return new Error(
`Failed prop type: The prop \`${args.key}\` expects a ${args.expected} in \`<Link>\`, but got \`${args.actual}\` instead.` +
(typeof window !== 'undefined'
? "\nOpen your browser's console to view the Component stack trace."
: '')
)
}

// TypeScript trick for type-guarding:
const requiredPropsGuard: Record<LinkPropsRequired, true> = {
href: true,
} as const
const requiredProps: LinkPropsRequired[] = Object.keys(
requiredPropsGuard
) as LinkPropsRequired[]
requiredProps.forEach((key: LinkPropsRequired) => {
if (key === 'href') {
if (
props[key] == null ||
(typeof props[key] !== 'string' && typeof props[key] !== 'object')
) {
throw createPropError({
key,
expected: '`string` or `object`',
actual: props[key] === null ? 'null' : typeof props[key],
})
}
} else {
// TypeScript trick for type-guarding:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _: never = key
}
})

// TypeScript trick for type-guarding:
const optionalPropsGuard: Record<LinkPropsOptional, true> = {
as: true,
replace: true,
scroll: true,
shallow: true,
passHref: true,
prefetch: true,
} as const
const optionalProps: LinkPropsOptional[] = Object.keys(
optionalPropsGuard
) as LinkPropsOptional[]
optionalProps.forEach((key: LinkPropsOptional) => {
if (key === 'as') {
if (
props[key] &&
typeof props[key] !== 'string' &&
typeof props[key] !== 'object'
) {
throw createPropError({
key,
expected: '`string` or `object`',
actual: typeof props[key],
})
}
} else if (
key === 'replace' ||
key === 'scroll' ||
key === 'shallow' ||
key === 'passHref' ||
key === 'prefetch'
) {
if (props[key] != null && typeof props[key] !== 'boolean') {
throw createPropError({
key,
expected: '`boolean`',
actual: typeof props[key],
})
}
} else {
// TypeScript trick for type-guarding:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _: never = key
}
})

// This hook is in a conditional but that is ok because `process.env.NODE_ENV` never changes
// eslint-disable-next-line react-hooks/rules-of-hooks
const hasWarned = React.useRef(false)
Expand Down Expand Up @@ -242,36 +335,4 @@ function Link(props: React.PropsWithChildren<LinkProps>) {
return React.cloneElement(child, childProps)
}

if (process.env.NODE_ENV === 'development') {
const warn = execOnce(console.error)

// This module gets removed by webpack.IgnorePlugin
const PropTypes = require('prop-types')
const exact = require('prop-types-exact')
// @ts-ignore the property is supported, when declaring it on the class it outputs an extra bit of code which is not needed.
Link.propTypes = exact({
href: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
as: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
prefetch: PropTypes.bool,
replace: PropTypes.bool,
shallow: PropTypes.bool,
passHref: PropTypes.bool,
scroll: PropTypes.bool,
children: PropTypes.oneOfType([
PropTypes.element,
(props: any, propName: string) => {
const value = props[propName]

if (typeof value === 'string') {
warn(
`Warning: You're using a string directly inside <Link>. This usage has been deprecated. Please add an <a> tag as child of <Link>`
)
}

return null
},
]).isRequired,
})
}

export default Link
1 change: 0 additions & 1 deletion packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@
"postcss": "7.0.32",
"process": "0.11.10",
"prop-types": "15.7.2",
"prop-types-exact": "1.2.0",
"react-is": "16.13.1",
"react-refresh": "0.8.3",
"resolve-url-loader": "3.1.1",
Expand Down
162 changes: 162 additions & 0 deletions test/acceptance/ReactRefreshLogBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1073,3 +1073,165 @@ test('logbox: anchors links in error messages', async () => {

await cleanup()
})

test('<Link> component props errors', async () => {
const [session, cleanup] = await sandbox()

await session.patch(
'index.js',
`
import Link from 'next/link'

export default function Hello() {
return <Link />
}
`
)

expect(await session.hasRedbox(true)).toBe(true)
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(
`"Error: Failed prop type: The prop \`href\` expects a \`string\` or \`object\` in \`<Link>\`, but got \`undefined\` instead."`
)

await session.patch(
'index.js',
`
import Link from 'next/link'

export default function Hello() {
return <Link href="/">Abc</Link>
}
`
)
expect(await session.hasRedbox()).toBe(false)

await session.patch(
'index.js',
`
import Link from 'next/link'

export default function Hello() {
return (
<Link
href="/"
as="/"
replace={false}
scroll={false}
shallow={false}
passHref={false}
prefetch={false}
>
Abc
</Link>
)
}
`
)
expect(await session.hasRedbox()).toBe(false)

await session.patch(
'index.js',
`
import Link from 'next/link'

export default function Hello() {
return (
<Link
href="/"
as="/"
replace={true}
scroll={true}
shallow={true}
passHref={true}
prefetch={true}
>
Abc
</Link>
)
}
`
)
expect(await session.hasRedbox()).toBe(false)

await session.patch(
'index.js',
`
import Link from 'next/link'

export default function Hello() {
return (
<Link
href="/"
as="/"
replace={undefined}
scroll={undefined}
shallow={undefined}
passHref={undefined}
prefetch={undefined}
>
Abc
</Link>
)
}
`
)
expect(await session.hasRedbox()).toBe(false)

await session.patch(
'index.js',
`
import Link from 'next/link'

export default function Hello() {
return (
<Link
href="/"
as="/"
replace={undefined}
scroll={'oops'}
shallow={undefined}
passHref={undefined}
prefetch={undefined}
>
Abc
</Link>
)
}
`
)
expect(await session.hasRedbox(true)).toBe(true)
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(`
"Error: Failed prop type: The prop \`scroll\` expects a \`boolean\` in \`<Link>\`, but got \`string\` instead.
Open your browser's console to view the Component stack trace."
`)

await session.patch(
'index.js',
`
import Link from 'next/link'

export default function Hello() {
return (
<Link
href={false}
as="/"
replace={undefined}
scroll={'oops'}
shallow={undefined}
passHref={undefined}
prefetch={undefined}
>
Abc
</Link>
)
}
`
)
expect(await session.hasRedbox(true)).toBe(true)
expect(await session.getRedboxDescription()).toMatchInlineSnapshot(`
"Error: Failed prop type: The prop \`href\` expects a \`string\` or \`object\` in \`<Link>\`, but got \`boolean\` instead.
Open your browser's console to view the Component stack trace."
`)

await cleanup()
})
12 changes: 0 additions & 12 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -14061,14 +14061,6 @@ promzard@^0.3.0:
dependencies:
read "1"

[email protected]:
version "1.2.0"
resolved "https://registry.yarnpkg.com/prop-types-exact/-/prop-types-exact-1.2.0.tgz#825d6be46094663848237e3925a98c6e944e9869"
dependencies:
has "^1.0.3"
object.assign "^4.1.0"
reflect.ownkeys "^0.2.0"

[email protected], prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
Expand Down Expand Up @@ -14556,10 +14548,6 @@ reduce-function-call@^1.0.1:
dependencies:
balanced-match "^1.0.0"

reflect.ownkeys@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460"

regenerate-unicode-properties@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e"
Expand Down