mirror of
https://github.com/pezkuwichain/pezkuwi-ui.git
synced 2026-10-20 18:56:22 +00:00
Move to @pezkuwi/dev 0.86 (eslint 10 toolchain)
- @pezkuwi/dev, dev-test, dev-ts ^0.86.3 - react-qr Display: the frames follow the value's content (xxhash), are reset while rendering instead of in an effect, and the frame timer is keyed on the frame set. Before, a value that went from one frame to several never started animating; Display.spec covers single, multi, single-to-multi and unmount. tsconfig build/spec know *.spec.tsx. - optional calls instead of guard expressions (ScanAddress, stores) - KeyringOptions as a Record; type assertions the receiver already accepts removed (Settings, vue-identicon) - disable directives narrowed to the rules that still report
This commit is contained in:
1 parent
6ee748883a
commit
53a730f92f
18 files changed
+2861
-3172
No files matched your search
@@ -0,0 +1,135 @@
|
||||
// Copyright 2017-2026 @pezkuwi/react-qr authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
|
||||
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { QrDisplay } from './Display.js';
|
||||
|
||||
// Bytes, so that a payload of this size is split into several QR frames.
|
||||
const MULTI = new Uint8Array(4096).map((_, i) => i % 256);
|
||||
const SINGLE = new Uint8Array([1, 2, 3, 4]);
|
||||
const DELAY = 40;
|
||||
|
||||
function sleep (ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function setup () {
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const el = document.createElement('div');
|
||||
const root = createRoot(el);
|
||||
const src = (): string | null => el.querySelector('img')?.getAttribute('src') ?? null;
|
||||
|
||||
return { el, root, src };
|
||||
}
|
||||
|
||||
/** Every distinct image shown while waiting `ms`. */
|
||||
async function imagesDuring (src: () => string | null, ms: number): Promise<Set<string>> {
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let t = 0; t < ms; t += DELAY / 2) {
|
||||
await act(async () => {
|
||||
await sleep(DELAY / 2);
|
||||
});
|
||||
|
||||
const s = src();
|
||||
|
||||
s && seen.add(s);
|
||||
}
|
||||
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe('QrDisplay', (): void => {
|
||||
it('shows a single frame and keeps it', async (): Promise<void> => {
|
||||
const { root, src } = setup();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<QrDisplay
|
||||
timerDelay={DELAY}
|
||||
value={SINGLE}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect({ image: src()?.startsWith('data:image/'), test: 'single' }).toEqual({ image: true, test: 'single' });
|
||||
expect({ frames: (await imagesDuring(src, DELAY * 4)).size, test: 'single' }).toEqual({ frames: 1, test: 'single' });
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('cycles through the frames of a multi-frame payload', async (): Promise<void> => {
|
||||
const { root, src } = setup();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<QrDisplay
|
||||
skipEncoding={false}
|
||||
timerDelay={DELAY}
|
||||
value={MULTI}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect({ cycles: (await imagesDuring(src, DELAY * 6)).size > 1, test: 'multi' }).toEqual({ cycles: true, test: 'multi' });
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('starts cycling when a single-frame value becomes a multi-frame one', async (): Promise<void> => {
|
||||
const { root, src } = setup();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<QrDisplay
|
||||
timerDelay={DELAY}
|
||||
value={SINGLE}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await imagesDuring(src, DELAY * 3);
|
||||
act(() => {
|
||||
root.render(
|
||||
<QrDisplay
|
||||
timerDelay={DELAY}
|
||||
value={MULTI}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect({ cycles: (await imagesDuring(src, DELAY * 6)).size > 1, test: 'single-to-multi' }).toEqual({ cycles: true, test: 'single-to-multi' });
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('stops its timer on unmount', async (): Promise<void> => {
|
||||
const { el, root } = setup();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<QrDisplay
|
||||
timerDelay={DELAY}
|
||||
value={MULTI}
|
||||
/>
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
// a timer left running would render into the unmounted root and throw
|
||||
await sleep(DELAY * 3);
|
||||
|
||||
expect(el.querySelector('img')).toEqual(null);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
// Copyright 2017-2026 @pezkuwi/react-qr authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { objectSpread } from '@pezkuwi/util';
|
||||
import { xxhashAsHex } from '@pezkuwi/util-crypto';
|
||||
|
||||
import { qrcode } from './qrcode.js';
|
||||
@@ -19,16 +18,11 @@ interface Props {
|
||||
value: Uint8Array;
|
||||
}
|
||||
|
||||
interface FrameState {
|
||||
frames: Uint8Array[];
|
||||
interface Shown {
|
||||
frameIdx: number;
|
||||
image: string | null;
|
||||
valueHash: string | null;
|
||||
}
|
||||
|
||||
interface TimerState {
|
||||
timerDelay: number;
|
||||
timerId: ReturnType<typeof setTimeout> | null;
|
||||
frames: Uint8Array[];
|
||||
skipEncoding: boolean;
|
||||
valueHash: string;
|
||||
}
|
||||
|
||||
const DEFAULT_FRAME_DELAY = 2750;
|
||||
@@ -45,75 +39,78 @@ function getDataUrl (value: Uint8Array): string {
|
||||
return qr.createDataURL(16, 0);
|
||||
}
|
||||
|
||||
function Display ({ className = '', size, skipEncoding, style = {}, timerDelay = DEFAULT_FRAME_DELAY, value }: Props): React.ReactElement<Props> | null {
|
||||
const [{ image }, setFrameState] = useState<FrameState>({ frameIdx: 0, frames: [], image: null, valueHash: null });
|
||||
const timerRef = useRef<TimerState>({ timerDelay, timerId: null });
|
||||
function encode (value: Uint8Array, skipEncoding: boolean, valueHash: string): Shown {
|
||||
return {
|
||||
frameIdx: 0,
|
||||
frames: skipEncoding
|
||||
? [value]
|
||||
: createFrames(value),
|
||||
skipEncoding,
|
||||
valueHash
|
||||
};
|
||||
}
|
||||
|
||||
function Display ({ className = '', size, skipEncoding = false, style = {}, timerDelay = DEFAULT_FRAME_DELAY, value }: Props): React.ReactElement<Props> | null {
|
||||
// The frames follow the content of value (by hash), not the identity of the
|
||||
// array, so a new array with the same bytes does not restart the display.
|
||||
const valueHash = useMemo(() => xxhashAsHex(value), [value]);
|
||||
const [shown, setShown] = useState<Shown>(() => encode(value, skipEncoding, valueHash));
|
||||
|
||||
// A new value (or encoding) starts again at its first frame. Adjusting state
|
||||
// while rendering, as React documents for state that follows props, rather
|
||||
// than in an effect, which renders the stale frame once more first.
|
||||
if (shown.valueHash !== valueHash || shown.skipEncoding !== skipEncoding) {
|
||||
setShown(encode(value, skipEncoding, valueHash));
|
||||
}
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => createImgSize(size),
|
||||
[size]
|
||||
);
|
||||
|
||||
// run on initial load to setup the global timer and provide and unsubscribe
|
||||
useEffect((): () => void => {
|
||||
const nextFrame = () => setFrameState((state): FrameState => {
|
||||
// when we have a single frame, we only ever fire once
|
||||
if (state.frames.length <= 1) {
|
||||
return state;
|
||||
}
|
||||
// Frames are encoded on demand, not up front: for a large payload that
|
||||
// keeps the first frame quick.
|
||||
const image = useMemo(
|
||||
() => getDataUrl(shown.frames[shown.frameIdx]),
|
||||
[shown]
|
||||
);
|
||||
|
||||
let frameIdx = state.frameIdx + 1;
|
||||
// Step through the frames of the current value. Each full cycle slows the
|
||||
// display a little. A single frame needs no timer; a new set of frames gets
|
||||
// a new one, so the display animates whenever the value has several frames.
|
||||
useEffect((): (() => void) | undefined => {
|
||||
const frames = shown.frames;
|
||||
|
||||
// when we overflow, skip to the first and slightly increase the delay between frames
|
||||
if (frameIdx === state.frames.length) {
|
||||
if (frames.length <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let frameIdx = 0;
|
||||
let delay = timerDelay;
|
||||
let timerId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const nextFrame = (): void => {
|
||||
frameIdx = frameIdx + 1;
|
||||
|
||||
if (frameIdx === frames.length) {
|
||||
frameIdx = 0;
|
||||
timerRef.current.timerDelay = timerRef.current.timerDelay + TIMER_INC;
|
||||
delay = delay + TIMER_INC;
|
||||
}
|
||||
|
||||
// only encode the frames on demand, not above as part of the
|
||||
// state derivation - in the case of large payloads, this should
|
||||
// be slightly more responsive on initial load
|
||||
const newState = objectSpread<FrameState>({}, state, {
|
||||
frameIdx,
|
||||
image: getDataUrl(state.frames[frameIdx])
|
||||
});
|
||||
setShown((state) =>
|
||||
state.frames === frames
|
||||
? { ...state, frameIdx }
|
||||
: state
|
||||
);
|
||||
timerId = setTimeout(nextFrame, delay);
|
||||
};
|
||||
|
||||
// set the new timer last
|
||||
timerRef.current.timerId = setTimeout(nextFrame, timerRef.current.timerDelay);
|
||||
|
||||
return newState;
|
||||
});
|
||||
|
||||
timerRef.current.timerId = setTimeout(nextFrame, timerRef.current.timerDelay);
|
||||
timerId = setTimeout(nextFrame, delay);
|
||||
|
||||
return (): void => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
timerRef.current.timerId && clearTimeout(timerRef.current.timerId);
|
||||
clearTimeout(timerId);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect((): void => {
|
||||
setFrameState((state): FrameState => {
|
||||
const valueHash = xxhashAsHex(value);
|
||||
|
||||
if (valueHash === state.valueHash) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const frames: Uint8Array[] = skipEncoding
|
||||
? [value]
|
||||
: createFrames(value);
|
||||
|
||||
// encode on demand
|
||||
return {
|
||||
frameIdx: 0,
|
||||
frames,
|
||||
image: getDataUrl(frames[0]),
|
||||
valueHash
|
||||
};
|
||||
});
|
||||
}, [skipEncoding, value]);
|
||||
}, [shown.frames, timerDelay]);
|
||||
|
||||
if (!image) {
|
||||
return null;
|
||||
|
||||
@@ -59,7 +59,7 @@ function ScanAddress ({ className, isEthereum, onError, onScan, size, style }: P
|
||||
|
||||
onScan({ content, genesisHash: genesisHash as HexString, isAddress, name: name?.length ? name.join(':') : undefined });
|
||||
} catch (error) {
|
||||
onError && onError(error as Error);
|
||||
onError?.(error as Error);
|
||||
|
||||
console.error('@pezkuwi/react-qr:QrScanAddress', (error as Error).message, data);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"exclude": [
|
||||
"**/*.spec.ts"
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../ui-settings/tsconfig.build.json" }
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.spec.ts"
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../react-qr/tsconfig.build.json" }
|
||||
|
||||
Reference in new issue
Block a user