Skip to content
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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"graphql": "^16.11.0",
"human-readable-checksum": "./package/human-readable-checksum-0.3.0.tgz",
"human-readable-checksum": "./package/human-readable-checksum-0.4.0.tgz",
"lucide-react": "^0.516.0",
"nuqs": "2.4.3",
"react": "19.1.1",
Expand Down
Binary file removed package/human-readable-checksum-0.3.0.tgz
Binary file not shown.
Binary file added package/human-readable-checksum-0.4.0.tgz
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, waitFor } from '@testing-library/react';
import { act, render, waitFor } from '@testing-library/react';
import { JSDOM } from 'jsdom';

import { CheckphraseReadyProvider } from '@/components/ui/composites/checkphrase-ready/CheckphraseReady';
Expand Down Expand Up @@ -50,15 +50,73 @@ jest.mock('@/components/ui/composites/text-with-copy/TextWithCopy', () => ({
}
}));

const renderedPhrases = () =>
((globalThis as { __checkPhrases?: string[] }).__checkPhrases ??= []);
const renderedPhrases = () => {
const holder = globalThis as { __checkPhrases?: string[] };
holder.__checkPhrases ??= [];
return holder.__checkPhrases;
};

const getChecksumMock = getChecksum as jest.Mock;

const ADDRESS = 'qz1234567890address';
const CHECK_PHRASE = 'alpha-bravo-charlie';

class MockIntersectionObserver {
static instances: MockIntersectionObserver[] = [];

static visible = true;

callback: IntersectionObserverCallback;

target: Element | null = null;

constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
MockIntersectionObserver.instances.push(this);
}

observe = (target: Element) => {
this.target = target;
this.emit(MockIntersectionObserver.visible);
};

emit(isIntersecting: boolean) {
if (!this.target) return;
this.callback(
[
{
isIntersecting,
target: this.target
} as IntersectionObserverEntry
],
this as unknown as IntersectionObserver
);
}

unobserve() {}

disconnect() {}

takeRecords(): IntersectionObserverEntry[] {
return [];
}

root = null;

rootMargin = '0px';

thresholds = [0];
}

describe('AccountAddressCell', () => {
beforeEach(() => {
MockIntersectionObserver.instances = [];
MockIntersectionObserver.visible = true;
Object.assign(globalThis, {
IntersectionObserver: MockIntersectionObserver
});
});

it('shows the address immediately and skeletons only the check phrase', async () => {
let resolveChecksum: (value: string) => void = () => {};
getChecksumMock.mockReturnValue(
Expand Down Expand Up @@ -120,8 +178,111 @@ describe('AccountAddressCell', () => {
);

await waitFor(() => {
expect(getChecksumMock).toHaveBeenCalledWith(ADDRESS);
expect(getChecksumMock).toHaveBeenCalledWith(
ADDRESS,
expect.any(AbortSignal)
);
});
});

it('computes a check phrase only after the address is on screen', async () => {
MockIntersectionObserver.visible = false;
getChecksumMock.mockClear();
getChecksumMock.mockReturnValue(new Promise(() => {}));

render(
<AccountAddressCell address={ADDRESS} href={`/accounts/${ADDRESS}`} />
);

await waitFor(() => {
expect(MockIntersectionObserver.instances.length).toBeGreaterThan(0);
});
expect(getChecksumMock).not.toHaveBeenCalled();

const observer = MockIntersectionObserver.instances.at(-1);
if (!observer) throw new Error('expected an intersection observer');

await act(async () => {
observer.emit(true);
});

await waitFor(() => {
expect(getChecksumMock).toHaveBeenCalledWith(
ADDRESS,
expect.any(AbortSignal)
);
});
});

it('stops a check phrase that leaves the screen before it finishes', async () => {
let signal: AbortSignal | undefined;
getChecksumMock.mockImplementation(
(_address: string, next?: AbortSignal) => {
signal = next;
return new Promise(() => {});
}
);

render(
<AccountAddressCell address={ADDRESS} href={`/accounts/${ADDRESS}`} />
);

await waitFor(() => {
expect(signal?.aborted).toBe(false);
});

const observer = MockIntersectionObserver.instances.at(-1);
if (!observer) throw new Error('expected an intersection observer');

await act(async () => {
observer.emit(false);
});

expect(signal?.aborted).toBe(true);
});

it('keeps a resolved check phrase visible when the address leaves and returns', async () => {
let calls = 0;
let resolveChecksum: (value: string) => void = () => {};
getChecksumMock.mockImplementation(() => {
calls += 1;
if (calls === 1) {
return new Promise((resolve) => {
resolveChecksum = resolve;
});
}
return new Promise(() => {});
});

const view = render(
<AccountAddressCell address={ADDRESS} href={`/accounts/${ADDRESS}`} />
);

await waitFor(() => {
expect(getChecksumMock).toHaveBeenCalled();
});
resolveChecksum(CHECK_PHRASE);

await waitFor(() => {
expect(view.getByText(CHECK_PHRASE)).toBeTruthy();
});

const observer = MockIntersectionObserver.instances.at(-1);
if (!observer) throw new Error('expected an intersection observer');

await act(async () => {
observer.emit(false);
});
expect(view.getByText(CHECK_PHRASE)).toBeTruthy();

await act(async () => {
observer.emit(true);
});

expect(
view.queryByRole('status', { name: 'Loading check phrase' })
).toBeNull();
expect(view.getByText(CHECK_PHRASE)).toBeTruthy();
});

it('does not offer the previous check phrase after the address changes', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,43 @@ export interface AccountAddressCellProps {
className?: string;
}

const useOnScreen = (ref: React.RefObject<HTMLElement | null>) => {
const [onScreen, setOnScreen] = React.useState(false);

React.useEffect(() => {
const element = ref.current;
if (!element) return undefined;

const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (!entry) return;
setOnScreen(entry.isIntersecting);
});

observer.observe(element);
return () => observer.disconnect();
}, [ref]);

return onScreen;
};

export const AccountAddressCell: React.FC<AccountAddressCellProps> = ({
address,
href,
text = address,
truncate = true,
className
}) => {
const rootRef = React.useRef<HTMLDivElement>(null);
const dataReady = useCheckphraseReady();
const { checksum, loading, error } = useChecksum(!dataReady, address);
const onScreen = useOnScreen(rootRef);
const { checksum, loading, error } = useChecksum(
!dataReady || !onScreen,
address
);

return (
<div className="flex flex-col items-start gap-1">
<div ref={rootRef} className="flex flex-col items-start gap-1">
<LinkWithCopy
href={href}
text={text}
Expand Down
22 changes: 16 additions & 6 deletions src/hooks/useChecksum.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';

import { getChecksum } from '@/utils/get-checksum';

Expand All @@ -8,25 +8,34 @@ type ResolvedChecksum = {
error: Error | null;
};

const isAbort = (error: unknown) =>
typeof error === 'object' &&
error !== null &&
'name' in error &&
error.name === 'AbortError';

export const useChecksum = (wait: boolean, id?: string) => {
const [resolved, setResolved] = useState<ResolvedChecksum | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const resolvedRef = useRef<ResolvedChecksum | null>(null);
resolvedRef.current = resolved;

useEffect(() => {
if (!id || wait) return;
if (!id || wait) return undefined;

const controller = new AbortController();
let cancelled = false;

const fetchChecksum = async () => {
setLoading(true);
if (resolvedRef.current?.id !== id) setLoading(true);

const fetchChecksum = async () => {
try {
const response = await getChecksum(id);
const response = await getChecksum(id, controller.signal);
if (cancelled) return;
setResolved({ id, checksum: response, error: null });
setLoading(false);
} catch (caught) {
if (cancelled) return;
if (cancelled || isAbort(caught)) return;
setResolved({
id,
checksum: null,
Expand All @@ -43,6 +52,7 @@ export const useChecksum = (wait: boolean, id?: string) => {

return () => {
cancelled = true;
controller.abort();
};
}, [id, wait]);

Expand Down
40 changes: 40 additions & 0 deletions src/utils/checksum-phrase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { addressToChecksum, loadWordList } from 'human-readable-checksum';

import { checksumPhrase } from './checksum-phrase';

jest.mock('human-readable-checksum', () => ({
loadWordList: jest.fn(() => ['word']),
addressToChecksum: jest.fn(() =>
Promise.resolve(['alpha', 'bravo', 'charlie', 'delta', 'echo'])
)
}));

const addressToChecksumMock = addressToChecksum as jest.Mock;
const loadWordListMock = loadWordList as jest.Mock;

describe('checksumPhrase', () => {
it('awaits the async check phrase and joins the words', async () => {
addressToChecksumMock.mockClear();

await expect(checksumPhrase('qz-async')).resolves.toBe(
'alpha-bravo-charlie-delta-echo'
);
expect(addressToChecksumMock).toHaveBeenCalledWith('qz-async', ['word']);

const loadsAfterFirst = loadWordListMock.mock.calls.length;
await expect(checksumPhrase('qz-async-again')).resolves.toBe(
'alpha-bravo-charlie-delta-echo'
);
expect(loadWordListMock.mock.calls.length).toBe(loadsAfterFirst);
});

it('rejects when the check phrase cannot be derived', async () => {
addressToChecksumMock.mockRejectedValueOnce(
new Error('Web Crypto is not available')
);

await expect(checksumPhrase('qz-derive-fail')).rejects.toThrow(
'Web Crypto is not available'
);
});
});
10 changes: 10 additions & 0 deletions src/utils/checksum-phrase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { addressToChecksum, loadWordList } from 'human-readable-checksum';

let wordList: string[] = [];

export const checksumPhrase = async (address: string) => {
if (wordList.length === 0) wordList = loadWordList();

const words = await addressToChecksum(address, wordList);
return words.join('-');
};
4 changes: 4 additions & 0 deletions src/utils/checksum-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const createChecksumWorker = (): Worker =>
new Worker(new URL('./checksum.worker.ts', import.meta.url), {
type: 'module'
});
Loading
Loading