Skip to content

fix: probe isWellFormed per call so Hermes uses the shim - #55

Open
gomesalexandre wants to merge 1 commit into
paulmillr:mainfrom
gomesalexandre:fix_iswellformed_per_call_probe
Open

fix: probe isWellFormed per call so Hermes uses the shim#55
gomesalexandre wants to merge 1 commit into
paulmillr:mainfrom
gomesalexandre:fix_iswellformed_per_call_probe

Conversation

@gomesalexandre

Copy link
Copy Markdown

closes #54

what

utf8.decode throws str.isWellFormed is not a function on Hermes (React Native). The native-vs-shim check for String.prototype.isWellFormed was resolved once at module load:

const _isWellFormed = (() =>
  typeof ('' as any).isWellFormed === 'function'
    ? (str) => (str as any).isWellFormed()
    : _isWellFormedShim)();

When Metro minifies the bundle it evaluates that probe against its own V8 (where isWellFormed exists), constant-folds the ternary to the native branch, and bakes in (str) => str.isWellFormed(). At Hermes runtime the method is absent, so the strict-UTF-8 well-formedness gate throws instead of falling back to the shim.

how

Probe the runtime argument at call time:

const _isWellFormed = (str: string): boolean =>
  typeof (str as any).isWellFormed === 'function'
    ? (str as any).isWellFormed()
    : _isWellFormedShim(str);

typeof str.isWellFormed depends on a value the bundler does not know at build time, so it cannot be constant-folded. Each engine takes the branch that actually works - native on V8, the encodeURI shim on Hermes. The only cost is a cheap typeof per call rather than one at load.

test

Adds utf8 env: well-formed check survives String.prototype.isWellFormed removal after load - it imports while isWellFormed exists, removes it (emulating the V8-minified / Hermes-runtime split), and checks utf8.decode still accepts a well-formed string and rejects a lone surrogate. It fails on the resolve-once version and passes with this fix.

$ npm test
✓ utf8 env: well-formed check survives String.prototype.isWellFormed removal after load
586 tests passed

_isWellFormed resolved the native String.prototype.isWellFormed probe once at
module load, inside a /* @__PURE__ */ IIFE. A bundler that minifies against V8
(Metro targeting Hermes) constant-folds that probe to the native branch and
bakes it in; on Hermes, where isWellFormed is absent, utf8.decode then calls a
missing method and throws.

Probe the runtime argument instead. typeof str.isWellFormed cannot be
constant-folded (str is not known at build time), so each engine takes the
branch that works - native on V8, the encodeURI shim on Hermes. The cost is a
cheap typeof per call rather than one at load.

Adds a regression test that imports with isWellFormed present, removes it, and
checks decode still accepts well-formed and rejects malformed strings. It fails
on the resolve-once version and passes here.

closes paulmillr#54
@paulmillr

Copy link
Copy Markdown
Owner

Can you measure perf before/after?

@paulmillr

Copy link
Copy Markdown
Owner

both native and shim
both before and after

so 4 cases

@gomesalexandre

Copy link
Copy Markdown
Author

Can you measure perf before/after?

both native and shim
both before and after
so 4 cases

Measured with the repo's jsbt bench (node v23.11), ns per iteration over a mixed corpus (short ASCII, a sentence, a 4096-char string, unicode). Lower is faster:

$ node benchmark/iswf.mts
native before (resolve-once),530,0.22
native after  (per-call),528,0.21
shim   before (resolve-once),16581,0.38
shim   after  (per-call),16473,0.37
case before (resolve-once) after (per-call)
native 530 ns 528 ns
shim 16581 ns 16473 ns

No measurable difference in any of the 4 cases: the per-call typeof is lost in the cost of the actual isWellFormed / encodeURI work (it's even marginally faster here, within the margin of error). The only place it shows at all is an adversarial single-character string, ~18 ns vs ~20 ns, i.e. ~2 ns of one property lookup on the smallest possible input. The shim being ~30x slower than native is inherent to encodeURI and unaffected by this change.

If you'd rather keep the resolve-once shape without the build-time fold, a lazy variant that resolves at the first runtime call and caches benches the same as resolve-once (native ~535 ns) and is still fold-safe:

let _iswf: ((s: string) => boolean) | undefined;
const _isWellFormed = (str: string): boolean => {
  if (_iswf === undefined)
    _iswf = typeof (str as any).isWellFormed === 'function'
      ? (s: string) => (s as any).isWellFormed()
      : _isWellFormedShim;
  return _iswf(str);
};

Happy to switch to that if you prefer it over the per-call probe.

benchmark script (benchmark/iswf.mts)
import { bench } from '@paulmillr/jsbt/benchmark.js';

const shim = (str: string): boolean => { try { return encodeURI(str) !== null; } catch { return false; } };
const before = (str: string): boolean => (str as any).isWellFormed(); // resolve-once bakes this in (native)
const after = (str: string): boolean =>
  typeof (str as any).isWellFormed === 'function' ? (str as any).isWellFormed() : shim(str);

const corpus = ['hello world', 'The quick brown fox. '.repeat(4), 'x'.repeat(4096), 'niño 日本語 🚀 '.repeat(64)];
const run = (f: (s: string) => boolean) => { for (const s of corpus) f(s); };

async function main() {
  await bench('native before (resolve-once)', () => run(before));
  await bench('native after  (per-call)', () => run(after));
  await bench('shim   before (resolve-once)', () => run(shim));
  const d = Object.getOwnPropertyDescriptor(String.prototype, 'isWellFormed');
  Object.defineProperty(String.prototype, 'isWellFormed', { value: undefined, configurable: true, writable: true });
  try { await bench('shim   after  (per-call)', () => run(after)); }
  finally { if (d) Object.defineProperty(String.prototype, 'isWellFormed', d); }
}
main();

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.

Issues with isWellFormed availability check on React Native (Babel/Metro)

2 participants