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

fix: `ReferenceError: l is not defined` in production builds (#621)

The vendored qrcodegen library used TypeScript `namespace`s. The bundled output for the namespace merge (`let t; … t ||= ns.QrCode ||= {}`) was mis-compiled by consumers' minifiers when down-levelling to ES2020 (e.g. Vite with esbuild), dropping the variable declaration and crashing at load time. The library now uses plain ES module exports and the published bundle targets ES2020, so nothing needs to be down-levelled.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ jobs:

- name: Run tests
run: pnpm test

- name: Build library and smoke test the production bundle
run: pnpm test:smoke
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:smoke": "pnpm build:lib && pnpm --filter \"./packages/**\" run test:smoke",
"lint": "oxlint",
"format": "oxfmt",
"format:check": "oxfmt --check",
Expand Down
5 changes: 4 additions & 1 deletion packages/react-qr-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
],
"type": "module",
"scripts": {
"build": "tsc -b && vite build"
"build": "tsc -b && vite build",
"test:smoke": "node scripts/smoke-test.mjs"
},
"types": "./dist/index.d.ts",
"main": "./dist/index.es.js",
Expand All @@ -54,6 +55,8 @@
"@types/react-dom": "^19.2.5",
"@typescript/typescript6": "^6.0.2",
"@vitejs/plugin-react": "^6.1.1",
"acorn": "^8.18.0",
"esbuild": "^0.28.2",
"globals": "^17.11.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
Expand Down
86 changes: 86 additions & 0 deletions packages/react-qr-code/scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/* oxlint-disable no-console -- CLI script, output is its purpose */
/**
* Production-bundle smoke test.
*
* Consumers do not run `dist/index.es.js` as-is: their bundler minifies it and
* down-levels it to their browser target. Some of those transforms have broken
* the library in the past (see #621: esbuild targeting ES2020 mis-compiled the
* TypeScript namespace emit into `ReferenceError: l is not defined`).
*
* Two checks:
*
* 1. The built bundle must parse as ES2020 (the tsconfig / vite `target`).
* If it does, no consumer has to down-level anything, so the class of bug
* behind #621 cannot occur regardless of which minifier version they run.
* This is deterministic and independent of esbuild's own bug history
* (esbuild fixed #621's mis-compilation in 0.28.2; we do not rely on that).
*
* 2. The bundle is run through esbuild at several targets, and each result is
* actually executed by rendering a QR code with react-dom/server.
*
* Usage: node scripts/smoke-test.mjs [path/to/index.es.js]
*/
import { mkdir, readFile, rm } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

import { parse } from 'acorn'
import { build } from 'esbuild'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'

const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const entry = resolve(process.argv[2] ?? resolve(pkgDir, 'dist/index.es.js'))
const outDir = resolve(pkgDir, 'dist/.smoke')
const targets = ['es2015', 'es2020', 'es2022', 'esnext']

await rm(outDir, { recursive: true, force: true })
await mkdir(outDir, { recursive: true })

let failed = false

try {
parse(await readFile(entry, 'utf8'), { ecmaVersion: 2020, sourceType: 'module' })
console.log('ok bundle parses as ES2020')
} catch (error) {
failed = true
console.error(
`FAIL bundle contains syntax newer than ES2020: ${error instanceof Error ? error.message : String(error)}`,
)
}

for (const target of targets) {
const outfile = resolve(outDir, `${target}.js`)
try {
await build({
entryPoints: [entry],
bundle: true,
minify: true,
format: 'esm',
target,
external: ['react', 'react-dom', 'react/jsx-runtime'],
outfile,
logLevel: 'silent',
})
const { ReactQRCode } = await import(pathToFileURL(outfile).href)
const html = renderToStaticMarkup(
createElement(ReactQRCode, { value: 'https://reactqrcode.com', level: 'H' }),
)
if (!html.includes('<svg'))
throw new Error(`rendered output has no <svg>: ${html.slice(0, 200)}`)
console.log(`ok ${target}`)
} catch (error) {
failed = true
console.error(
`FAIL ${target}: ${error instanceof Error ? error.message : String(error)}`,
)
}
}

await rm(outDir, { recursive: true, force: true })

if (failed) {
console.error(`\nSmoke test failed for ${entry}`)
process.exit(1)
}
console.log(`\nSmoke test passed for ${entry}`)
10 changes: 5 additions & 5 deletions packages/react-qr-code/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import qrcodegen from './lib/qrcodegen'
import { Ecc } from './lib/qrcodegen'
import type {
DataModulesStyle,
ERROR_LEVEL_MAPPED_TYPE,
Expand All @@ -11,10 +11,10 @@ import type {
* Error correction level map.
*/
export const ERROR_LEVEL_MAP: ERROR_LEVEL_MAPPED_TYPE = {
L: qrcodegen.QrCode.Ecc.LOW,
M: qrcodegen.QrCode.Ecc.MEDIUM,
Q: qrcodegen.QrCode.Ecc.QUARTILE,
H: qrcodegen.QrCode.Ecc.HIGH,
L: Ecc.LOW,
M: Ecc.MEDIUM,
Q: Ecc.QUARTILE,
H: Ecc.HIGH,
} as const

/**
Expand Down
8 changes: 4 additions & 4 deletions packages/react-qr-code/src/hooks/use-qr-code.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMemo } from 'react'

import { ERROR_LEVEL_MAP } from '../constants'
import qrcodegen from '../lib/qrcodegen'
import { QrCode, QrSegment } from '../lib/qrcodegen'
import type { ErrorCorrectionLevel, ImageSettings } from '../types/lib'
import { getImageSettings, getMarginSize } from '../utils/qr-code'

Expand All @@ -24,11 +24,11 @@ export const useQRCode = ({
}) => {
const qrcode = useMemo(() => {
const values = Array.isArray(value) ? value : [value]
const segments = values.reduce<qrcodegen.QrSegment[]>((accum, v) => {
accum.push(...qrcodegen.QrSegment.makeSegments(v))
const segments = values.reduce<QrSegment[]>((accum, v) => {
accum.push(...QrSegment.makeSegments(v))
return accum
}, [])
return qrcodegen.QrCode.encodeSegments(
return QrCode.encodeSegments(
segments,
ERROR_LEVEL_MAP[level],
minVersion,
Expand Down
4 changes: 3 additions & 1 deletion packages/react-qr-code/src/lib/qrcodegen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ Obtained via https://github.com/nayuki/QR-Code-generator/blob/942f4319a6ba913dbc

## Modifications:

- Export for use as a module
- Converted from TypeScript `namespace`s to plain ES module exports (`QrCode`, `QrSegment`, `Ecc`, `Mode`).
The namespace-merge emit (`let t; … t ||= ns.QrCode ||= {}`) was mis-compiled by consumers' minifiers
when down-levelling to ES2020, producing `ReferenceError: l is not defined` in production builds (#621).
- Added `getModules` method to `QrCode` class, to bypass excessive calls to `getModule`.
Loading