Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

fix(site): updateuseClipboard to work better with effect logic#20183

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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
Parkreiner merged 7 commits intomainfrommes/clipboard-update
Oct 6, 2025
Merged
Show file tree
Hide file tree
Changes fromall 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
6 changes: 2 additions & 4 deletionssite/src/components/CopyButton/CopyButton.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,9 +19,7 @@ export const CopyButton: FC<CopyButtonProps> = ({
label,
...buttonProps
}) => {
const { showCopiedSuccess, copyToClipboard } = useClipboard({
textToCopy: text,
});
const { showCopiedSuccess, copyToClipboard } = useClipboard();

return (
<TooltipProvider>
Expand All@@ -30,7 +28,7 @@ export const CopyButton: FC<CopyButtonProps> = ({
<Button
size="icon"
variant="subtle"
onClick={copyToClipboard}
onClick={() =>copyToClipboard(text)}
{...buttonProps}
>
{showCopiedSuccess ? <CheckIcon /> : <CopyIcon />}
Expand Down
6 changes: 3 additions & 3 deletionssite/src/components/CopyableValue/CopyableValue.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,10 +16,10 @@ export const CopyableValue: FC<CopyableValueProps> = ({
children,
...attrs
}) => {
const { showCopiedSuccess, copyToClipboard } = useClipboard({
textToCopy: value,
const { showCopiedSuccess, copyToClipboard } = useClipboard();
const clickableProps = useClickable<HTMLSpanElement>(() => {
copyToClipboard(value);
});
const clickableProps = useClickable<HTMLSpanElement>(copyToClipboard);

return (
<Tooltip
Expand Down
4 changes: 3 additions & 1 deletionsite/src/hooks/useClickable.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@ import {
type RefObject,
useRef,
} from "react";
import { useEffectEvent } from "./hookPolyfills";

// Literally any object (ideally an HTMLElement) that has a .click method
type ClickableElement = {
Expand DownExpand Up@@ -43,10 +44,11 @@ export const useClickable = <
role?: TRole,
): UseClickableResult<TElement, TRole> => {
const ref = useRef<TElement>(null);
const stableOnClick = useEffectEvent(onClick);

return {
ref,
onClick,
onClick: stableOnClick,
tabIndex: 0,
role: (role ?? "button") as TRole,

Expand Down
110 changes: 96 additions & 14 deletionssite/src/hooks/useClipboard.test.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,9 +9,11 @@
* immediately pollutes the tests with false negatives. Even if something should
* fail, it won't.
*/
import { act, renderHook, screen } from "@testing-library/react";

import { renderHook, screen } from "@testing-library/react";
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
import { ThemeOverride } from "contexts/ThemeProvider";
import { act } from "react";
import themes, { DEFAULT_THEME } from "theme";
import {
COPY_FAILED_MESSAGE,
Expand DownExpand Up@@ -115,8 +117,8 @@ function setupMockClipboard(isSecure: boolean): SetupMockClipboardResult {
};
}

function renderUseClipboard<TInput extends UseClipboardInput>(inputs: TInput) {
Copy link
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

I have no idea why I made this generic originally

return renderHook<UseClipboardResult,TInput>(
function renderUseClipboard(inputs?: UseClipboardInput) {
return renderHook<UseClipboardResult,UseClipboardInput>(
(props) => useClipboard(props),
{
initialProps: inputs,
Expand DownExpand Up@@ -188,9 +190,9 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {

const assertClipboardUpdateLifecycle = async (
result: RenderResult,
textToCheck: string,
textToCopy: string,
): Promise<void> => {
await act(() => result.current.copyToClipboard());
await act(() => result.current.copyToClipboard(textToCopy));
expect(result.current.showCopiedSuccess).toBe(true);

// Because of timing trickery, any timeouts for flipping the copy status
Expand All@@ -203,35 +205,35 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
await act(() => jest.runAllTimersAsync());

const clipboardText = getClipboardText();
expect(clipboardText).toEqual(textToCheck);
expect(clipboardText).toEqual(textToCopy);
};

it("Copies the current text to the user's clipboard", async () => {
const textToCopy = "dogs";
const { result } = renderUseClipboard({ textToCopy });
const { result } = renderUseClipboard();
await assertClipboardUpdateLifecycle(result, textToCopy);
});

it("Should indicate to components not to show successful copy after a set period of time", async () => {
const textToCopy = "cats";
const { result } = renderUseClipboard({ textToCopy });
const { result } = renderUseClipboard();
await assertClipboardUpdateLifecycle(result, textToCopy);
expect(result.current.showCopiedSuccess).toBe(false);
});

it("Should notify the user of an error using the provided callback", async () => {
const textToCopy = "birds";
const onError = jest.fn();
const { result } = renderUseClipboard({textToCopy,onError });
const { result } = renderUseClipboard({ onError });

setSimulateFailure(true);
await act(() => result.current.copyToClipboard());
await act(() => result.current.copyToClipboard(textToCopy));
expect(onError).toBeCalled();
});

it("Should dispatch a new toast message to the global snackbar when errors happen while no error callback is provided to the hook", async () => {
const textToCopy = "crow";
const { result } = renderUseClipboard({ textToCopy });
const { result } = renderUseClipboard();

/**
* @todo Look into why deferring error-based state updates to the global
Expand All@@ -241,7 +243,7 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
* flushed through the GlobalSnackbar component afterwards
*/
setSimulateFailure(true);
await act(() => result.current.copyToClipboard());
await act(() => result.current.copyToClipboard(textToCopy));

const errorMessageNode = screen.queryByText(COPY_FAILED_MESSAGE);
expect(errorMessageNode).not.toBeNull();
Expand All@@ -252,11 +254,91 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => {
// Snackbar state transitions that you might get if the hook uses the
// default
const textToCopy = "hamster";
const { result } = renderUseClipboard({ textToCopy, onError: jest.fn() });
const { result } = renderUseClipboard({ onError: jest.fn() });

setSimulateFailure(true);
await act(() => result.current.copyToClipboard(textToCopy));

expect(result.current.error).toBeInstanceOf(Error);
});

it("Clears out existing errors if a new copy operation succeeds", async () => {
const text = "dummy-text";
const { result } = renderUseClipboard();
setSimulateFailure(true);
await act(() => result.current.copyToClipboard());

await act(() => result.current.copyToClipboard(text));
expect(result.current.error).toBeInstanceOf(Error);

setSimulateFailure(false);
await assertClipboardUpdateLifecycle(result, text);
expect(result.current.error).toBeUndefined();
});

// This test case is really important to ensure that it's easy to plop this
// inside of useEffect calls without having to think about dependencies too
// much
it("Ensures that the copyToClipboard function always maintains a stable reference across all re-renders", async () => {
const initialOnError = jest.fn();
const { result, rerender } = renderUseClipboard({
onError: initialOnError,
clearErrorOnSuccess: true,
});
const initialCopy = result.current.copyToClipboard;

// Re-render arbitrarily with no clipboard state transitions to make
// sure that a parent re-rendering doesn't break anything
rerender({ onError: initialOnError });
expect(result.current.copyToClipboard).toBe(initialCopy);

// Re-render with new onError prop and then swap back to simplify
// testing
rerender({ onError: jest.fn() });
expect(result.current.copyToClipboard).toBe(initialCopy);
rerender({ onError: initialOnError });

// Re-render with a new clear value then swap back to simplify testing
rerender({ onError: initialOnError, clearErrorOnSuccess: false });
expect(result.current.copyToClipboard).toBe(initialCopy);
rerender({ onError: initialOnError, clearErrorOnSuccess: true });

// Trigger a failed clipboard interaction
setSimulateFailure(true);
await act(() => result.current.copyToClipboard("dummy-text-2"));
expect(result.current.copyToClipboard).toBe(initialCopy);

/**
* Trigger a successful clipboard interaction
*
* @todo For some reason, using the assertClipboardUpdateLifecycle
* helper triggers Jest errors with it thinking that values are being
* accessed after teardown, even though the problem doesn't exist for
* any other test case.
*
* It's not a huge deal, because we only need to inspect React after the
* interaction, instead of the full DOM, but for correctness, it would
* be nice if we could get this issue figured out.
*/
setSimulateFailure(false);
await act(() => result.current.copyToClipboard("dummy-text-2"));
expect(result.current.copyToClipboard).toBe(initialCopy);
});

it("Always uses the most up-to-date onError prop", async () => {
const initialOnError = jest.fn();
const { result, rerender } = renderUseClipboard({
onError: initialOnError,
});
setSimulateFailure(true);

const secondOnError = jest.fn();
rerender({ onError: secondOnError });
await act(() => result.current.copyToClipboard("dummy-text"));

expect(initialOnError).not.toHaveBeenCalled();
expect(secondOnError).toHaveBeenCalledTimes(1);
expect(secondOnError).toHaveBeenCalledWith(
"Failed to copy text to clipboard",
);
});
});
77 changes: 41 additions & 36 deletionssite/src/hooks/useClipboard.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
import { displayError } from "components/GlobalSnackbar/utils";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffectEvent } from "./hookPolyfills";

const CLIPBOARD_TIMEOUT_MS = 1_000;
export const COPY_FAILED_MESSAGE = "Failed to copy text to clipboard";
export const HTTP_FALLBACK_DATA_ID = "http-fallback";

export type UseClipboardInput = Readonly<{
textToCopy: string;

/**
* Optional callback to call when an error happens. If not specified, the hook
* will dispatch an error message to the GlobalSnackbar
*/
onError?: (errorMessage: string) => void;
clearErrorOnSuccess?: boolean;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

I was going to say, as a gut reaction, I feel like I would expect this to clear the error on success by default, but actually it looks like we never useerror anywhere? Maybe we could even drop storing and exposing the error for now?

Copy link
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Yeah, I can see that. I feel like we'lleventually need it, and the code is already set up to account for it in the tests, so I think merging it in now has the least friction. But I'll look into removing the error behavior in a bit

Copy link
MemberAuthor

@ParkreinerParkreinerOct 6, 2025
edited
Loading

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

For context, theclearErrorOnSuccess prop isn't "new", because it was already in the Registry codebase for a few months after I copied this file over there

}>;

export type UseClipboardResult = Readonly<{
copyToClipboard: () => Promise<void>;
copyToClipboard: (textToCopy: string) => Promise<void>;
error: Error | undefined;

/**
Expand All@@ -40,47 +36,56 @@ export type UseClipboardResult = Readonly<{
showCopiedSuccess: boolean;
}>;

export const useClipboard = (input: UseClipboardInput): UseClipboardResult => {
const { textToCopy, onError: errorCallback } = input;
export const useClipboard = (input?: UseClipboardInput): UseClipboardResult => {
const { onError = displayError, clearErrorOnSuccess = true } = input ?? {};

const [showCopiedSuccess, setShowCopiedSuccess] = useState(false);
const [error, setError] = useState<Error>();
const timeoutIdRef = useRef<number | undefined>(undefined);

useEffect(() => {
const clearIdOnUnmount = () => window.clearTimeout(timeoutIdRef.current);
return clearIdOnUnmount;
const clearTimeoutOnUnmount = () => {
window.clearTimeout(timeoutIdRef.current);
};
return clearTimeoutOnUnmount;
}, []);

const handleSuccessfulCopy = () => {
const stableOnError = useEffectEvent(() => onError(COPY_FAILED_MESSAGE));
const handleSuccessfulCopy = useEffectEvent(() => {
setShowCopiedSuccess(true);
if (clearErrorOnSuccess) {
setError(undefined);
}

timeoutIdRef.current = window.setTimeout(() => {
setShowCopiedSuccess(false);
}, CLIPBOARD_TIMEOUT_MS);
};

const copyToClipboard = async () => {
try {
await window.navigator.clipboard.writeText(textToCopy);
handleSuccessfulCopy();
} catch (err) {
const fallbackCopySuccessful = simulateClipboardWrite(textToCopy);
if (fallbackCopySuccessful) {
handleSuccessfulCopy();
return;
}
});

const wrappedErr = new Error(COPY_FAILED_MESSAGE);
if (err instanceof Error) {
wrappedErr.stack = err.stack;
const copyToClipboard = useCallback(
async (textToCopy: string) => {
try {
await window.navigator.clipboard.writeText(textToCopy);
handleSuccessfulCopy();
} catch (err) {
const fallbackCopySuccessful = simulateClipboardWrite(textToCopy);
if (fallbackCopySuccessful) {
handleSuccessfulCopy();
return;
}

const wrappedErr = new Error(COPY_FAILED_MESSAGE);
if (err instanceof Error) {
wrappedErr.stack = err.stack;
}

console.error(wrappedErr);
setError(wrappedErr);
stableOnError();
}

console.error(wrappedErr);
setError(wrappedErr);

const notifyUser = errorCallback ?? displayError;
notifyUser(COPY_FAILED_MESSAGE);
}
};
},
[stableOnError, handleSuccessfulCopy],
);

return { showCopiedSuccess, error, copyToClipboard };
};
Expand Down
15 changes: 8 additions & 7 deletionssite/src/pages/CliAuthPage/CliAuthPageView.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,10 +12,7 @@ interface CliAuthPageViewProps {
}

exportconstCliAuthPageView:FC<CliAuthPageViewProps>=({ sessionToken})=>{
constclipboard=useClipboard({
textToCopy:sessionToken??"",
});

constclipboardState=useClipboard();
return(
<SignInLayout>
<Welcome>Session token</Welcome>
Expand All@@ -30,16 +27,20 @@ export const CliAuthPageView: FC<CliAuthPageViewProps> = ({ sessionToken }) => {
className="w-full"
size="lg"
disabled={!sessionToken}
onClick={clipboard.copyToClipboard}
onClick={()=>{
if(sessionToken){
clipboardState.copyToClipboard(sessionToken);
}
}}
>
{clipboard.showCopiedSuccess ?(
{clipboardState.showCopiedSuccess ?(
<CheckIcon/>
) :(
<Spinnerloading={!sessionToken}>
<CopyIcon/>
</Spinner>
)}
{clipboard.showCopiedSuccess
{clipboardState.showCopiedSuccess
?"Session token copied!"
:"Copy session token"}
</Button>
Expand Down
Loading
Loading

[8]ページ先頭

©2009-2025 Movatter.jp