-
Notifications
You must be signed in to change notification settings - Fork 221
fix: resolve conditional React Hook calls in Link and Script shims #223
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -280,19 +280,15 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link( | |
| // where href is a route pattern like "/user/[id]" and as is "/user/1") | ||
| const resolvedHref = as ?? resolveHref(href); | ||
|
|
||
| // Block dangerous URI schemes (javascript:, data:, vbscript:). | ||
| // Render an inert <a> without href to prevent XSS while preserving | ||
| // styling and attributes like className, id, aria-*. | ||
| if (typeof resolvedHref === "string" && isDangerousScheme(resolvedHref)) { | ||
| if (process.env.NODE_ENV !== "production") { | ||
| console.warn(`<Link> blocked dangerous href: ${resolvedHref}`); | ||
| } | ||
| const { passHref: _p, ...safeProps } = restWithoutLocale; | ||
| return <a {...safeProps}>{children}</a>; | ||
| } | ||
|
|
||
| // Apply locale prefix if specified | ||
| const localizedHref = applyLocaleToHref(resolvedHref, locale); | ||
| const isDangerous = | ||
| typeof resolvedHref === "string" && isDangerousScheme(resolvedHref); | ||
|
|
||
| // Apply locale prefix if specified (safe even for dangerous hrefs since we | ||
| // won't use the result when isDangerous is true) | ||
| const localizedHref = applyLocaleToHref( | ||
| isDangerous ? "/" : resolvedHref, | ||
| locale, | ||
| ); | ||
| // Full href with basePath for browser URLs and fetches | ||
| const fullHref = withBasePath(localizedHref); | ||
|
|
||
|
|
@@ -307,7 +303,7 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link( | |
| // Prefetching: observe the element when it enters the viewport. | ||
| // prefetch={false} disables, prefetch={true} or undefined/null (default) enables. | ||
| const internalRef = useRef<HTMLAnchorElement | null>(null); | ||
| const shouldPrefetch = prefetchProp !== false; | ||
| const shouldPrefetch = prefetchProp !== false && !isDangerous; | ||
|
|
||
| const setRefs = useCallback( | ||
| (node: HTMLAnchorElement | null) => { | ||
|
|
@@ -474,6 +470,17 @@ const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link( | |
|
|
||
| const linkStatusValue = React.useMemo(() => ({ pending }), [pending]); | ||
|
|
||
| // Block dangerous URI schemes (javascript:, data:, vbscript:). | ||
| // Render an inert <a> without href to prevent XSS while preserving | ||
| // styling and attributes like className, id, aria-*. | ||
| // This check is placed after all hooks to satisfy the Rules of Hooks. | ||
| if (isDangerous) { | ||
| if (process.env.NODE_ENV !== "production") { | ||
| console.warn(`<Link> blocked dangerous href: ${resolvedHref}`); | ||
| } | ||
| return <a {...anchorProps}>{children}</a>; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small behavioral difference from the original code: the old early return used One thing the old code did not pass through was |
||
| } | ||
|
|
||
| return ( | ||
| <LinkStatusContext.Provider value={linkStatusValue}> | ||
| <a ref={setRefs} href={fullHref} onClick={handleClick} {...anchorProps}> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -107,26 +107,10 @@ function Script(props: ScriptProps): React.ReactElement | null { | |
| } = props; | ||
|
|
||
| const hasMounted = useRef(false); | ||
|
|
||
| // SSR path: only "beforeInteractive" renders a <script> tag server-side | ||
| if (typeof window === "undefined") { | ||
| if (strategy === "beforeInteractive") { | ||
| const scriptProps: Record<string, unknown> = { ...rest }; | ||
| if (src) scriptProps.src = src; | ||
| if (id) scriptProps.id = id; | ||
| if (dangerouslySetInnerHTML) { | ||
| scriptProps.dangerouslySetInnerHTML = dangerouslySetInnerHTML; | ||
| } | ||
| return React.createElement("script", scriptProps, children); | ||
| } | ||
| // Other strategies don't render during SSR | ||
| return null; | ||
| } | ||
|
|
||
| const key = id ?? src ?? ""; | ||
|
|
||
| // Client path: load scripts via useEffect based on strategy | ||
| // eslint-disable-next-line react-hooks/rules-of-hooks | ||
| // Client path: load scripts via useEffect based on strategy. | ||
| // useEffect never runs during SSR, so it's safe to call unconditionally. | ||
| useEffect(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the cleanest fix of the two. Nicely done. |
||
| if (hasMounted.current) return; | ||
| hasMounted.current = true; | ||
|
|
@@ -203,6 +187,21 @@ function Script(props: ScriptProps): React.ReactElement | null { | |
| } | ||
| }, [src, id, strategy, onLoad, onReady, onError, children, dangerouslySetInnerHTML, key, rest]); | ||
|
|
||
| // SSR path: only "beforeInteractive" renders a <script> tag server-side | ||
| if (typeof window === "undefined") { | ||
| if (strategy === "beforeInteractive") { | ||
| const scriptProps: Record<string, unknown> = { ...rest }; | ||
| if (src) scriptProps.src = src; | ||
| if (id) scriptProps.id = id; | ||
| if (dangerouslySetInnerHTML) { | ||
| scriptProps.dangerouslySetInnerHTML = dangerouslySetInnerHTML; | ||
| } | ||
| return React.createElement("script", scriptProps, children); | ||
| } | ||
| // Other strategies don't render during SSR | ||
| return null; | ||
| } | ||
|
|
||
| // The component itself renders nothing — scripts are injected imperatively | ||
| return null; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The substitution of
"/"for the dangerous href is a reasonable way to keepapplyLocaleToHrefandwithBasePathfrom operating on ajavascript:string. However, this meanslocalizedHref,fullHref,handleClick, and the prefetchuseEffectall execute with a valid-looking"/"path when the href is actually dangerous.This is fine today because:
<a>from having anhreforonClick/handleClickattached, so the prefetched"/"and the click handler are never used.useEffectat line 317 will set up an IntersectionObserver for"/"and attempt to prefetch the root page — this is a harmless but unnecessary side effect.Worth guarding the prefetch effect to avoid the wasted work:
Actually, the substitution itself is fine — but consider also skipping prefetch setup when
isDangerousis true. You could add!isDangerousto theshouldPrefetchcondition, or add an early return inside the prefetchuseEffect. Minor nit though, not a blocker.