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
40 changes: 40 additions & 0 deletions packages/worker/client/ensure-crypto-random-uuid.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { expect, test } from 'vitest'
import { ensureCryptoRandomUUID } from './ensure-crypto-random-uuid.ts'

const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i

test('ensureCryptoRandomUUID polyfills missing randomUUID from getRandomValues and leaves an existing implementation alone', () => {
const bytes = new Uint8Array(16).fill(0xab)
const getRandomValues = (target: Uint8Array) => {
target.set(bytes.subarray(0, target.length))
return target
}
const cryptoWithoutUuid = {
getRandomValues,
} as Crypto

ensureCryptoRandomUUID(cryptoWithoutUuid)

expect(typeof cryptoWithoutUuid.randomUUID).toBe('function')
const first = cryptoWithoutUuid.randomUUID()
expect(first).toMatch(uuidPattern)
expect(first[14]).toBe('4')
expect(['8', '9', 'a', 'b']).toContain(first[19]!.toLowerCase())

const existing = () => 'already-present-uuid'
const cryptoWithUuid = {
getRandomValues,
randomUUID: existing,
} as Crypto

ensureCryptoRandomUUID(cryptoWithUuid)
expect(cryptoWithUuid.randomUUID).toBe(existing)
expect(cryptoWithUuid.randomUUID()).toBe('already-present-uuid')

const empty = {} as Crypto
ensureCryptoRandomUUID(empty)
expect(empty.randomUUID).toBeUndefined()

ensureCryptoRandomUUID(undefined)
})
42 changes: 42 additions & 0 deletions packages/worker/client/ensure-crypto-random-uuid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Remix's reconcile runtime calls `crypto.randomUUID()` for frame ids.
* Some in-app browsers (e.g. Twitter/X on iOS) expose `crypto` /
* `getRandomValues` but not `randomUUID`, which aborts client hydration.
* Install a RFC 4122 v4 polyfill before `run()` when needed.
*/
export function ensureCryptoRandomUUID(
cryptoApi: Crypto | undefined = globalThis.crypto,
): void {
if (!cryptoApi) return
if (typeof cryptoApi.randomUUID === 'function') return
if (typeof cryptoApi.getRandomValues !== 'function') return

const polyfill = function randomUUID(
this: Crypto,
): `${string}-${string}-${string}-${string}-${string}` {
const bytes = new Uint8Array(16)
this.getRandomValues(bytes)
// Version 4 + RFC 4122 variant bits.
bytes[6] = (bytes[6]! & 0x0f) | 0x40
bytes[8] = (bytes[8]! & 0x3f) | 0x80
const hex = Array.from(bytes, (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` as `${string}-${string}-${string}-${string}-${string}`
}

try {
Object.defineProperty(cryptoApi, 'randomUUID', {
configurable: true,
enumerable: false,
writable: true,
value: polyfill,
})
} catch {
try {
cryptoApi.randomUUID = polyfill
} catch {
// Leave crypto unchanged; Remix will still throw if it calls randomUUID.
}
}
}
3 changes: 3 additions & 0 deletions packages/worker/client/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
initSentryClient,
} from '#client/sentry-client.ts'
import { AppRoot, APP_ROOT_ENTRY_ID } from './app-root.tsx'
import { ensureCryptoRandomUUID } from './ensure-crypto-random-uuid.ts'

// Remix frame ids call crypto.randomUUID(); some in-app browsers omit it.
ensureCryptoRandomUUID()
initSentryClient(document)

const clientRegistry: Record<string, typeof AppRoot> = {
Expand Down
Loading