authorgravatar for bompus@users.noreply.github.comAaron Queen <bompus@users.noreply.github.com> 2026-09-15 17:10:24-06:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 23:10:24+00:00
log6f7aaffe26f0e6ae3d47b0ae512b322f890e0f43
tree7dd842144831ed098a110cd4ae42e0c1b6d51876
parent87a12b53fdff7e2e0318af3edea54005557cea56
signature Signed by PGP key B5690EEEBB952194

fix(contracts): avoid Intl.Segmenter in monogram validation (Hermes crash) (#11984)


1 files changed, 24 insertions(+), 2 deletions(-)

packages/contracts/src/orchestration.ts+24-2
......@@ -459,11 +459,33 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check(
459459
460460const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32));
461461
462const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
462// Hermes (the Android/iOS JS runtime) has no Intl.Segmenter, and this module
463// loads at app startup, so `new Intl.Segmenter()` crashes mobile on launch.
464// Count grapheme clusters with a small approximation instead: a code point
465// continues the current cluster when it is a combining mark, joiner,
466// variation selector, or emoji modifier, or follows a ZWJ. This must stay
467// runtime-independent (not `typeof Intl.Segmenter` feature detection) so the
468// shared contract validates identically on server, web, and mobile.
469const GRAPHEME_CONTINUATION = /[\p{M}\u200c\u200d\ufe0f\u{1F3FB}-\u{1F3FF}]/u;
470
471const countGraphemes = (text: string): number => {
472 let clusters = 0;
473 let prevJoiner = false;
474 for (const char of text) {
475 if (clusters > 0 && (prevJoiner || GRAPHEME_CONTINUATION.test(char))) {
476 prevJoiner = char === "\u200d";
477 continue;
478 }
479 clusters += 1;
480 prevJoiner = char === "\u200d";
481 }
482 return clusters;
483};
484
463485export const ProjectMonogramText = TrimmedNonEmptyString.check(
464486 Schema.isMaxLength(32),
465487 Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u),
466 Schema.makeFilter((text) => Array.from(monogramSegmenter.segment(text)).length <= 2),
488 Schema.makeFilter((text) => countGraphemes(text) <= 2),
467489);
468490
469491export const ProjectIconOverride = Schema.Union([