Skip to content

Generate the unique hash lazily so renders stay deterministic - #254

Open
pedrosousa13 wants to merge 1 commit into
gilbarbara:mainfrom
pedrosousa13:fix-nondeterministic-hash-on-render
Open

Generate the unique hash lazily so renders stay deterministic#254
pedrosousa13 wants to merge 1 commit into
gilbarbara:mainfrom
pedrosousa13:fix-nondeterministic-hash-on-render

Conversation

@pedrosousa13

@pedrosousa13 pedrosousa13 commented Sep 4, 2026

Copy link
Copy Markdown

Fixes #253.

The problem

src/modules/useInlineSVG.ts:27

const hash = useRef(uniqueHash ?? randomString(8));

useRef's argument is evaluated eagerly on every render, and ?? evaluates its right operand whenever uniqueHash is undefined — so randomString(8) runs on each render even though only the first result is kept.

That makes the component impossible to statically prerender under Next.js 16 with cacheComponents, which patches Math.random at build time and aborts the enclosing Suspense boundary when it is called. The abort is silent, and it freezes every later boundary as client-rendered in the stored artifact. Full mechanism and repro in #253.

The change

Hold uniqueHash in the ref and fill in a random string on first read:

const hashRef = useRef(uniqueHash);

const getHash = useCallback(() => {
  const hash = hashRef.current ?? randomString(8);

  hashRef.current = hash;

  return hash;
}, []);

The two read sites — the useReducer initializer and getElement — now call getHash().

This works because the hash is only read when uniquifyIDs is set (getNode returns early otherwise at utils.ts:59, updateSVGAttributes at utils.ts:115), and only once content exists. Content loading happens in the mount effect behind canUseDOM(), so on a server render the hash is never reached — and now never generated.

getHash is memoized deliberately: an unstable identity would flow into getElement's dependency array and re-trigger the LOADED -> READY effect on every render.

I kept the syntax at ES2020 to match tsconfig.json's target. ??= would read better here, but it is ES2021 and tsdown emits it unchanged, so it would have been the first logical-assignment operator in the shipped bundle — a syntax-floor bump for a package that supports react: 16.8 - 19.

Behaviour

Unchanged in every case:

  • a supplied uniqueHash is used verbatim, exactly as before;
  • an omitted one still resolves to one stable random string for the lifetime of the component;
  • an empty-string uniqueHash is still preserved (?? does not treat '' as nullish, same as before);
  • a uniqueHash that arrives after mount is still ignored, as it was before.

I deliberately avoided useId(): it would be the semantically ideal source for this, but it needs React 18+ and the package supports 16.8 - 19. Worth revisiting if that floor ever moves.

Tests

New test/prerender.spec.tsx:

  • Math.random is never called during renderToStaticMarkup, across a remote URL, a data URI, uniquifyIDs, and a custom uniqueHash;
  • ids are still uniquified once content renders on the client;
  • rendering from a warm cache under StrictMode yields a single hash for every id. That path — the cached branch of the reducer initializer — is the only place the hash is read during a render, so it is the one that most needed covering.

Verified these are real regression tests, not tautologies: with src/modules/useInlineSVG.ts reverted to main and the tests left in place, 3 of the 6 fail. (The other 3 should pass either way — a custom uniqueHash short-circuits the old expression, and the two client-side tests assert behaviour the old code also had.)

pnpm run validate is green — lint, typecheck, 100 tests (94 before, 6 new), build, size limit (7.13 kB ESM against the 10 kB budget), and attw.

@codesandbox

codesandbox Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

@pedrosousa13
pedrosousa13 force-pushed the fix-nondeterministic-hash-on-render branch from a4af8b0 to 27894a7 Compare September 4, 2026 09:11
`randomString(8)` was passed as the `useRef` initial value, so `Math.random()`
ran on every render whenever `uniqueHash` was not supplied — `??` evaluates its
right operand each time regardless of whether the ref keeps the result.

The hash is only read when `uniquifyIDs` is set, and only after the SVG content
has loaded, which requires the DOM. It is therefore never needed during a server
render. Generate it on first read instead.

This unblocks static prerendering in frameworks that reject non-deterministic
values read during render. Next.js with `cacheComponents` enabled patches
`Math.random` and aborts the enclosing Suspense boundary when it is called, which
silently drops the component — and every later boundary — from the prerendered
HTML.

No behaviour change: a supplied `uniqueHash` is still used verbatim, and an
omitted one still resolves to a stable random string for the lifetime of the
component.
@pedrosousa13
pedrosousa13 force-pushed the fix-nondeterministic-hash-on-render branch from 27894a7 to 5b668af Compare September 4, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Math.random() on every render breaks static prerendering (Next.js cacheComponents)

1 participant