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
5 changes: 5 additions & 0 deletions .changeset/fix-random-size-hydration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lglab/react-qr-code': patch
---

Fix `randomSize` breaking server-side rendering. `getScaleFactor` called `Math.random()` during render, so the server and the client produced different module sizes, causing a hydration mismatch, and static prerenders froze one arbitrary result into the HTML. Scale factors are now derived deterministically from the module's position and a seed hashed from the code's pre-excavation module grid: the same `value` always renders the same layout, different values still get different layouts, resizing an excavating image no longer reshuffles the rest of the code, and SSR output matches hydration.
56 changes: 28 additions & 28 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,45 +10,45 @@
"lint": "oxlint --config .oxlintrc.json"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@hookform/resolvers": "^5.7.1",
"@lglab/react-qr-code": "workspace:*",
"@radix-ui/react-accordion": "^1.2.16",
"@radix-ui/react-checkbox": "^1.3.7",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-popover": "^1.1.19",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slider": "^1.4.3",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.17",
"@radix-ui/react-toast": "^1.2.19",
"@radix-ui/react-toggle": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.12",
"@radix-ui/react-accordion": "^1.2.20",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slider": "^1.4.7",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toast": "^1.2.23",
"@radix-ui/react-toggle": "^1.1.18",
"@radix-ui/react-tooltip": "^1.2.16",
"@uiw/react-color": "^2.10.3",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.24.0",
"next": "16.2.10",
"lucide-react": "^1.28.0",
"next": "16.3.0",
"next-themes": "^0.4.6",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.81.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.84.0",
"react-icons": "^5.7.0",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.2",
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.19",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.3"
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"postcss": "^8.5.25",
"tailwindcss": "^4.3.3",
"typescript": "~7.0.2"
}
}
}
30 changes: 15 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "git",
"url": "https://github.com/LGLabGreg/react-qr-code.git"
},
"packageManager": "pnpm@11.13.0",
"packageManager": "pnpm@11.20.0",
"type": "module",
"scripts": {
"dev": "pnpm concurrently --names packages,apps \"pnpm watch:packages\" \"pnpm watch:docs\"",
Expand Down Expand Up @@ -34,23 +34,23 @@
},
"devDependencies": {
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
"@testing-library/jest-dom": "^6.9.1",
"@changesets/cli": "^2.31.1",
"@testing-library/jest-dom": "^7.0.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package files =="
git ls-files | rg '(^|/)(package\.json|vite\.config\.(ts|js|r?mts?|cts?)|vitest\.(config|setup)\.(ts|js|r?mts?|cts?)|\.config/vitest.*)$' || true

echo
echo "== package.json deps around `@testing-library` =="
if [ -f package.json ]; then
  node -e "const p=require('./package.json'); console.log('test:', JSON.stringify(Object.fromEntries(Object.entries(p.devDependencies||{}).filter(([k])=>k.includes('testing')||k.includes('vitest')||k.includes('jest')), null, 2)))"
fi

echo
echo "== vitest setup import(s) =="
for f in $(git ls-files | rg '(^|/)vitest\.setup\.(ts|js|m?ts)$|(^|/)vite\.config\.(ts|js)$'); do
  echo "-- $f"
  sed -n '1,160p' "$f" | nl -ba -v1 | rg -n '`@testing-library/jest-dom`|vite/config|test|globals|setupFiles|environment' -C 2 || true
done

echo
echo "== package manager lock files =="
git ls-files | rg '(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|bun\.lock)$' || true

Repository: LGLabGreg/react-qr-code

Length of output: 716


🌐 Web query:

@testing-library/jest-dom Vitest import @testing-library/jest-dom/vitest documentation

💡 Result:

To use @testing-library/jest-dom with Vitest, you must import the dedicated Vitest entry point in your test setup file [1][2]. Setup Instructions: 1. Create a setup file (e.g., vitest-setup.ts or vitest-setup.js) and add the following import [1][2]: import '@testing-library/jest-dom/vitest'; 2. Update your vitest.config.js (or vitest.config.ts) to include this file in the setupFiles configuration [1][2]: export default defineConfig({ test: { setupFiles: ['./vitest-setup.ts'], // or your filename }, }); 3. If you are using TypeScript, ensure your setup file is included in your tsconfig.json and that the necessary types are configured. Add @testing-library/jest-dom to your compilerOptions.types to ensure the matchers are correctly recognized by your IDE and TypeScript compiler [1][2]: { "compilerOptions": { "types": ["vitest/globals", "@testing-library/jest-dom"] }, "include": [ "./vitest-setup.ts" ] } Using the /vitest entry point is required for compatibility, as it correctly hooks into Vitest's expect interface rather than the global Jest object [1][3][2]. If you encounter type errors, verifying that the setup file is included in your tsconfig and that the types are referenced in the configuration is often the solution [4][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== vitest.setup.ts =="
python3 - <<'PY'
from pathlib import Path
for p in ["vitest.setup.ts", "vitest.config.ts", "vitest.config.js"]:
    try:
        text = Path(p).read_text()
    except FileNotFoundError:
        continue
    print(f"-- {p} ({len(text.splitlines())} lines)")
    for i, line in enumerate(text.splitlines(), 1):
        if 1 <= i <= 220:
            print(f"{i:4d}: {line}")
PY

echo
echo "== check setupFiles =="
python3 - <<'PY'
from pathlib import Path
import re
p=Path("vitest.config.ts")
if p.exists():
    text=p.read_text()
    print(text)
PY

Repository: LGLabGreg/react-qr-code

Length of output: 1436


Use the Vitest entry point for @testing-library/jest-dom.

vitest.setup.ts is loaded by the Vitest setup path and imports the Jest entry point. Since this setup files DOM matchers, import @testing-library/jest-dom/vitest so Vitest matchers are registered correctly.

Suggested setup change
-import '`@testing-library/jest-dom`'
+import '`@testing-library/jest-dom/vitest`'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 38, Update the Vitest setup path in vitest.setup.ts to
import the Vitest-specific `@testing-library/jest-dom/vitest` entry point instead
of the Jest entry point, while preserving the existing DOM matcher setup.

Source: MCP tools

"@testing-library/react": "^16.3.2",
"@types/jsdom": "^28.0.3",
"@types/node": "^26.1.1",
"@vitejs/plugin-react": "^6.0.3",
"@types/node": "^26.1.2",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "4.1.10",
"concurrently": "^10.0.3",
"concurrently": "^10.0.4",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"oxfmt": "^0.59.0",
"oxlint": "^1.74.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"typescript": "~6.0.3",
"vite": "^8.1.4",
"jsdom": "^30.0.1",
"lint-staged": "^17.3.0",
"oxfmt": "^0.62.0",
"oxlint": "^1.77.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"typescript": "~7.0.2",
"vite": "^8.2.0",
"vitest": "^4.1.10"
}
}
}
19 changes: 10 additions & 9 deletions packages/react-qr-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,16 @@
"react": "^18 || ^19"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"globals": "^17.7.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"typescript": "~6.0.3",
"vite": "^8.1.4",
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@typescript/typescript6": "^6.0.2",
"@vitejs/plugin-react": "^6.0.5",
"globals": "^17.9.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"typescript": "~7.0.2",
"vite": "^8.2.0",
"vite-plugin-dts": "^5.0.3"
}
}
5 changes: 5 additions & 0 deletions packages/react-qr-code/src/components/data-modules.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ describe('DataModules', () => {

const defaultProps = {
modules: mockModules,
// No excavation in these cases, so the pre-excavation grid is the same.
cells: mockModules,
margin: 2,
gradientId: 'mock-gradient-id',
}
Expand Down Expand Up @@ -63,6 +65,7 @@ describe('DataModules', () => {
render(
<DataModules
modules={modules}
cells={modules}
margin={2}
gradientId='mock-gradient-id'
settings={{ style: 'circuit-board', color: '#ffdd99' }}
Expand Down Expand Up @@ -111,6 +114,7 @@ describe('DataModules', () => {
render(
<DataModules
modules={modules}
cells={modules}
margin={2}
gradientId='mock-gradient-id'
settings={{ style: 'circuit-board', color: '#ffdd99' }}
Expand Down Expand Up @@ -138,6 +142,7 @@ describe('DataModules', () => {
render(
<DataModules
modules={modules}
cells={modules}
margin={2}
gradientId='mock-gradient-id'
settings={{ style: 'circuit-board', color: '#000000' }}
Expand Down
10 changes: 7 additions & 3 deletions packages/react-qr-code/src/components/data-modules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
circuitBoardShouldDrawPad,
dataModuleCanBeRandomSize,
diamond,
getModulesSeed,
getRenderableDataModuleNeighbours,
getScaleFactor,
leaf,
Expand All @@ -33,6 +34,7 @@ import { hashtag, heart, pinchedSquare, star } from '../utils/svg'

export const DataModules = ({
modules,
cells,
margin,
settings,
gradient,
Expand All @@ -47,9 +49,11 @@ export const DataModules = ({
const numCells = modules.length
const isRandom = dataModuleCanBeRandomSize(style) && randomSize

const seed = useMemo(() => (isRandom ? getModulesSeed(cells) : 0), [isRandom, cells])

const scaleFactor = useCallback(
() => getScaleFactor(style, isRandom, size),
[style, isRandom, size],
(x: number, y: number) => getScaleFactor(style, isRandom, size, x, y, seed),
[style, isRandom, size, seed],
)

modules.forEach((row, y) => {
Expand All @@ -62,7 +66,7 @@ export const DataModules = ({
return
}

const scale = scaleFactor()
const scale = scaleFactor(x, y)
const size = 1 * scale
const posOffset = (1 - 1 * scale) / 2
const baseX = x + margin
Expand Down
2 changes: 1 addition & 1 deletion packages/react-qr-code/src/lib/qrcodegen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,7 @@ namespace qrcodegen {
if (!QrSegment.isNumeric(digits))
throw new RangeError('String contains non-numeric characters')
let bb: Array<bit> = []
for (let i = 0; i < digits.length; ) {
for (let i = 0; i < digits.length;) {
// Consume up to 3 digits per iteration
const n: int = Math.min(digits.length - i, 3)
appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb)
Expand Down
64 changes: 64 additions & 0 deletions packages/react-qr-code/src/react-qr-code.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,70 @@ describe('ReactQRCode', () => {
expect(path).toBeInTheDocument()
expect(path).toHaveAttribute('fill', dataModulesSettings.color)
})

describe('randomSize', () => {
const dataModulesSettings: DataModulesSettings = {
style: 'circle',
randomSize: true,
}

const renderPath = (props = {}) => {
const { unmount } = render(
<ReactQRCode
value='test'
dataModulesSettings={dataModulesSettings}
{...props}
/>,
)
const d = screen.getByTestId('data-modules').getAttribute('d')
unmount()
return d
}

it('renders identically across renders of the same value', () => {
expect(renderPath()).toBe(renderPath())
})

it('renders differently for a different value', () => {
expect(renderPath()).not.toBe(renderPath({ value: 'other' }))
})

// Each circle is `M{x},{y}a{r},{r} ...`, where r is half the module's scale
// and the position is inset by half the scale, so the grid cell and its
// scale can both be recovered from the path.
const modulesByCell = (d: string | null) => {
const byCell = new Map<string, number>()
for (const [, x, y, r] of d!.matchAll(/M(-?[\d.]+),(-?[\d.]+)a(-?[\d.]+),/g)) {
const scale = Number(r) * 2
const inset = (1 - scale) / 2
const cell = `${Math.round(Number(x) - inset)},${Math.round(Number(y) - inset)}`
byCell.set(cell, scale)
}
return byCell
}

it('keeps module scales stable when an excavating image is resized', () => {
const imageSettings = {
src: 'https://example.com/logo.png',
height: 24,
width: 24,
excavate: true,
}

const small = modulesByCell(renderPath({ imageSettings }))
const large = modulesByCell(
renderPath({ imageSettings: { ...imageSettings, height: 40, width: 40 } }),
)

// The larger logo excavates more, so it renders strictly fewer modules.
expect(large.size).toBeLessThan(small.size)

// Every module that survives both excavations keeps the same scale.
large.forEach((scale, cell) => {
expect(small.get(cell)).toBe(scale)
})
})
})
})

describe('Finder patterns outer', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-qr-code/src/react-qr-code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ const ReactQRCode = forwardRef<ReactQRCodeRef, ReactQRCodeProps>((props, ref) =>
/>
<FinderPatternsOuter settings={finderPatternOuterSettings} {...svgElementsProps} />
<FinderPatternsInner settings={finderPatternInnerSettings} {...svgElementsProps} />
<DataModules settings={dataModulesSettings} {...svgElementsProps} />
<DataModules settings={dataModulesSettings} cells={cells} {...svgElementsProps} />
{image}
</svg>
)
Expand Down
3 changes: 3 additions & 0 deletions packages/react-qr-code/src/types/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export interface FinderPatternsInnerProps extends GeneratePathFnProps {

export interface DataModulesProps extends GeneratePathFnProps {
settings?: DataModulesSettings
// Grid before excavation. `randomSize` seeds from this so that resizing an
// excavating image does not reshuffle every module in the code.
cells: Modules
}

export interface DataModulesNeighbours {
Expand Down
Loading