Skip to content

Commit 4a99fd5

Browse files
author
João Dias
committed
feat(hooks): refactor useAutoId for better SSR and type safety
- Refactor useAutoId to always return a string (no undefined) - Add support for custom ID length via options parameter - Improve SSR safety by generating IDs during initialization - Add new utility functions: generateUUID - Update tests to cover all ID generation scenarios - Fix React.useId detection for better compatibility - Add proper TypeScript types and documentation BREAKING CHANGE: useAutoId now always returns a string instead of string | undefined
1 parent 217fb92 commit 4a99fd5

File tree

6 files changed

+260
-118
lines changed

6 files changed

+260
-118
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* The copyright of this file belongs to Feedzai. The file cannot be
3+
* reproduced in whole or in part, stored in a retrieval system, transmitted
4+
* in any form, or by any means electronic, mechanical, or otherwise, without
5+
* the prior permission of the owner. Please refer to the terms of the license
6+
* agreement.
7+
*
8+
* (c) 2025 Feedzai, Rights Reserved.
9+
*/
10+
11+
import { generateUUID } from "src/functions";
12+
13+
describe("generateUUID", () => {
14+
it("generates a UUID", () => {
15+
const id = generateUUID();
16+
expect(id).to.exist;
17+
expect(typeof id).to.equal("string");
18+
});
19+
20+
it("should generate a compliant RFC 4122 UUID", () => {
21+
const id = generateUUID();
22+
expect(id).to.match(
23+
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
24+
);
25+
});
26+
});

cypress/test/hooks/use-auto-id.cy.tsx

Lines changed: 85 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
* (c) 2024 Feedzai, Rights Reserved.
55
*/
66
import React from "react";
7-
import { useAutoId } from "src/hooks";
7+
import { useAutoId, generateFallbackId, generateUUIDFragment, initializeState } from "src/hooks";
88

9-
function DemoComponent({ value = null, prefix }: { value?: string | null; prefix?: string }) {
9+
function DemoComponent({ value, prefix }: { value?: string | null; prefix?: string }) {
1010
const firstId = useAutoId(value, prefix);
1111
const secondId = useAutoId();
1212
return (
@@ -21,88 +21,132 @@ function FallbackDemo({
2121
value = "feedzai-fallback-id",
2222
prefix,
2323
}: {
24-
value?: string | null;
24+
value?: string;
2525
prefix?: string;
2626
}) {
2727
const id = useAutoId(value, prefix);
2828
return <h1 id={id}>Feedzai</h1>;
2929
}
3030

31+
describe("generateFallbackId", () => {
32+
it("generates a fallback ID", () => {
33+
const id = generateFallbackId();
34+
expect(id).to.exist;
35+
expect(typeof id).to.equal("string");
36+
});
37+
});
38+
39+
describe("generateUUIDFragment", () => {
40+
it("generates a UUID fragment", () => {
41+
const id = generateUUIDFragment();
42+
expect(id).to.exist;
43+
expect(typeof id).to.equal("string");
44+
expect(id.length).to.equal(8);
45+
});
46+
47+
it("generates a UUID fragment of a given length", () => {
48+
const id = generateUUIDFragment(16);
49+
expect(id).to.exist;
50+
expect(typeof id).to.equal("string");
51+
expect(id.length).to.equal(16);
52+
});
53+
});
54+
55+
describe("initializeState", () => {
56+
it("generates a UUID fragment if no ID is provided", () => {
57+
// @ts-expect-error - This is a test for the fallback behavior
58+
const id = initializeState();
59+
expect(id).to.exist;
60+
expect(typeof id).to.equal("string");
61+
});
62+
63+
it("generates a UUID fragment of a given length if no ID is provided", () => {
64+
const id = initializeState(undefined, { length: 16 });
65+
expect(id).to.exist;
66+
expect(typeof id).to.equal("string");
67+
expect(id.length).to.equal(16);
68+
});
69+
70+
it("returns the provided ID if it is a string", () => {
71+
const id = initializeState("feedzai-id");
72+
expect(id).to.equal("feedzai-id");
73+
});
74+
});
75+
3176
describe("useAutoId", () => {
32-
context("React pre-useId", () => {
77+
it("generates a prefixed fallback ID", () => {
78+
cy.mount(<DemoComponent prefix="fdz-prefix" />);
79+
80+
console.log(process.env.NODE_ENV);
81+
82+
cy.findByText("A paragraph")
83+
.invoke("attr", "id")
84+
.should("match", /^fdz-prefix--:?[a-z0-9:]+:?$/);
85+
});
86+
87+
context("React <18 (no useId support)", () => {
3388
beforeEach(() => {
34-
// Mock React.useId to be undefined
3589
cy.stub(React, "useId").as("useIdStub").value(undefined);
3690
});
3791

38-
it("should generate a unique ID value", () => {
92+
it("generates unique IDs using fallback generator", () => {
3993
cy.mount(<DemoComponent />);
4094

4195
cy.findByText("A paragraph")
4296
.invoke("attr", "id")
43-
.then((idOne) => {
44-
cy.findByText("An inline span element").invoke("attr", "id").should("not.equal", idOne);
45-
});
46-
});
47-
48-
it("should generate a prefixed unique ID value", () => {
49-
const expected = "feedzai-a-prefix";
50-
cy.mount(<DemoComponent value={undefined} prefix={expected} />);
97+
.then((firstId) => {
98+
expect(firstId).to.exist;
5199

52-
cy.findByText("A paragraph").invoke("attr", "id").should("contain", expected);
100+
cy.findByText("An inline span element")
101+
.invoke("attr", "id")
102+
.should("exist")
103+
.and("not.equal", firstId);
104+
});
53105
});
54106

55-
it("uses a fallback ID", () => {
107+
it("uses a static fallback ID if provided", () => {
56108
cy.mount(<FallbackDemo />);
57109

58110
cy.findByText("Feedzai").should("have.id", "feedzai-fallback-id");
59111
});
60112

61-
it("should return a prefixed fallback ID", () => {
62-
cy.mount(<FallbackDemo prefix="js-prefix" value="423696e5" />);
113+
it("applies a prefix to the custom fallback ID", () => {
114+
cy.mount(<FallbackDemo value="423696e5" prefix="js-prefix" />);
63115

64116
cy.findByText("Feedzai").should("have.id", "js-prefix--423696e5");
65117
});
66118
});
67119

68-
context("React 18+ with useId", () => {
69-
const GENERATED_ID = ":r0:";
70-
71-
beforeEach(() => {
72-
// Mock React.useId to return a predictable value
73-
cy.stub(React, "useId").as("useIdStub").returns(GENERATED_ID);
74-
});
75-
76-
it("should use React.useId for generating IDs", () => {
120+
context("React 18+ (with useId)", () => {
121+
it("uses React.useId to generate consistent IDs", () => {
77122
cy.mount(<DemoComponent />);
78123

79-
cy.findByText("A paragraph").invoke("attr", "id").should("equal", GENERATED_ID);
80-
cy.findByText("An inline span element").invoke("attr", "id").should("equal", GENERATED_ID);
81-
cy.get("@useIdStub").should("be.calledTwice");
124+
cy.findByText("A paragraph")
125+
.invoke("attr", "id")
126+
.should("match", /^:?[a-z0-9:]+:?$/);
127+
cy.findByText("An inline span element")
128+
.invoke("attr", "id")
129+
.should("match", /^:?[a-z0-9:]+:?$/);
82130
});
83131

84-
it("should generate a prefixed unique ID value using React.useId", () => {
85-
const expected = "feedzai-a-prefix";
86-
cy.mount(<DemoComponent value={undefined} prefix={expected} />);
132+
it("applies prefix to ID generated by React.useId", () => {
133+
cy.mount(<DemoComponent prefix="fdz-js-prefix" />);
87134

88135
cy.findByText("A paragraph")
89136
.invoke("attr", "id")
90-
.should("equal", `${expected}--${GENERATED_ID}`);
91-
cy.get("@useIdStub").should("be.called");
137+
.should("match", /^fdz-js-prefix--:?[a-z0-9:]+:?$/);
92138
});
93139

94-
it("uses a fallback ID even when React.useId is available", () => {
140+
it("uses static fallback ID when explicitly provided", () => {
95141
cy.mount(<FallbackDemo />);
96142

97143
cy.findByText("Feedzai").should("have.id", "feedzai-fallback-id");
98-
cy.get("@useIdStub").should("not.be.called");
99144
});
100145

101-
it("should return a prefixed fallback ID and not use React.useId", () => {
102-
cy.mount(<FallbackDemo prefix="js-prefix" value="423696e5" />);
146+
it("applies prefix to provided fallback ID (ignoring useId)", () => {
147+
cy.mount(<FallbackDemo value="423696e5" prefix="fdz-js-prefix" />);
103148

104-
cy.findByText("Feedzai").should("have.id", "js-prefix--423696e5");
105-
cy.get("@useIdStub").should("not.be.called");
149+
cy.findByText("Feedzai").should("have.id", "fdz-js-prefix--423696e5");
106150
});
107151
});
108152
});

src/functions/string/make-id.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,8 @@
99
* Joins strings to format IDs for compound components.
1010
*
1111
* @example
12-
*
13-
* // Custom generated id by using the `useAutoId` hook and the `makeId` function
14-
* // to join the auto-generated id with a custom string
15-
* const autoId = useAutoId(id);
16-
* const { current: generatedId } = useRef(makeId("fdz-js-tabbable-button-", autoId));
12+
* makeId("fdz-js-tabbable-button", "123")
13+
* // => "fdz-js-tabbable--button-123"
1714
*
1815
* @param args
1916
* @returns {string}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* The copyright of this file belongs to Feedzai. The file cannot be
3+
* reproduced in whole or in part, stored in a retrieval system, transmitted
4+
* in any form, or by any means electronic, mechanical, or otherwise, without
5+
* the prior permission of the owner. Please refer to the terms of the license
6+
* agreement.
7+
*
8+
* (c) 2025 Feedzai, Rights Reserved.
9+
*/
10+
11+
import { isFunction, isNil } from "../typed";
12+
13+
let cryptoRef: Crypto | undefined;
14+
15+
try {
16+
cryptoRef =
17+
typeof globalThis !== "undefined" && globalThis.crypto ? globalThis.crypto : undefined;
18+
} catch {
19+
cryptoRef = undefined;
20+
}
21+
22+
const CAN_USE_CRYPTO = !isNil(cryptoRef);
23+
const CAN_USE_CRYPTO_RANDOM_UUID = Boolean(
24+
CAN_USE_CRYPTO && isFunction(globalThis.crypto.randomUUID)
25+
);
26+
const CAN_USE_CRYPTO_GET_RANDOM_VALUES = Boolean(
27+
CAN_USE_CRYPTO && isFunction(globalThis.crypto.getRandomValues)
28+
);
29+
30+
/**
31+
* Generates a UUID using the fallback implementation (RFC 4122 version 4)
32+
*
33+
* @returns {string} A unique identifier
34+
*/
35+
function generateFallbackUUID() {
36+
// Fallback implementation (RFC 4122 version 4)
37+
const bytes = CAN_USE_CRYPTO_GET_RANDOM_VALUES
38+
? cryptoRef!.getRandomValues(new Uint8Array(16))
39+
: Array.from({ length: 16 }, () => Math.floor(Math.random() * 256));
40+
41+
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Set version to 0100
42+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Set variant to 10
43+
44+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
45+
46+
return (
47+
hex.slice(0, 4).join("") +
48+
"-" +
49+
hex.slice(4, 6).join("") +
50+
"-" +
51+
hex.slice(6, 8).join("") +
52+
"-" +
53+
hex.slice(8, 10).join("") +
54+
"-" +
55+
hex.slice(10, 16).join("")
56+
);
57+
}
58+
59+
/**
60+
* Generates a UUID using crypto.randomUUID if available, otherwise falls back to a fallback ID
61+
*
62+
* @example
63+
* ```ts
64+
* import { generateUUID } from "@feedzai/react-utilities";
65+
*
66+
* const uuid = generateUUID(); // "123e4567-e89b-12d3-a456-426614174000"
67+
* ```
68+
*
69+
* @returns {string} A unique identifier
70+
*/
71+
export function generateUUID() {
72+
if (CAN_USE_CRYPTO_RANDOM_UUID) {
73+
try {
74+
return cryptoRef!.randomUUID();
75+
} catch {
76+
return generateFallbackUUID();
77+
}
78+
}
79+
80+
return generateFallbackUUID();
81+
}

src/functions/utilities/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from "./call-if-exists";
22
export * from "./empty-function";
33
export * from "./make-cancelable";
44
export * from "./throw-error";
5+
export * from "./generate-uuid";

0 commit comments

Comments
 (0)