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:
Satoshi Qazi Muhammed authored and SatoshiQaziMuhammed committed 2026-10-09 16:24:01 +04:00
1 parent 6ee748883a
commit 53a730f92f
18 files changed
+2861 -3172

No files matched your search

+135
View File
@@ -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);
});
});
+62 -65
View File
@@ -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;
+1 -1
View File
@@ -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);
}
+2 -1
View File
@@ -6,7 +6,8 @@
"rootDir": "./src"
},
"exclude": [
"**/*.spec.ts"
"**/*.spec.ts",
"**/*.spec.tsx"
],
"references": [
{ "path": "../ui-settings/tsconfig.build.json" }
+2 -1
View File
@@ -8,7 +8,8 @@
"noEmit": true
},
"include": [
"**/*.spec.ts"
"**/*.spec.ts",
"**/*.spec.tsx"
],
"references": [
{ "path": "../react-qr/tsconfig.build.json" }