diff --git a/packages/worker/client/ensure-crypto-random-uuid.node.test.ts b/packages/worker/client/ensure-crypto-random-uuid.node.test.ts new file mode 100644 index 0000000000..da0882c1bd --- /dev/null +++ b/packages/worker/client/ensure-crypto-random-uuid.node.test.ts @@ -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) +}) diff --git a/packages/worker/client/ensure-crypto-random-uuid.ts b/packages/worker/client/ensure-crypto-random-uuid.ts new file mode 100644 index 0000000000..42079d290f --- /dev/null +++ b/packages/worker/client/ensure-crypto-random-uuid.ts @@ -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. + } + } +} diff --git a/packages/worker/client/entry.tsx b/packages/worker/client/entry.tsx index 67a44aa161..c37c26c47b 100644 --- a/packages/worker/client/entry.tsx +++ b/packages/worker/client/entry.tsx @@ -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 = {