From ee9a664dc37e16a05094301dac8e59e3522f083b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20=C5=BBelawski?= Date: Fri, 10 Jul 2026 11:35:20 +0200 Subject: [PATCH 1/2] feat: bundle mode integration + relevant cleanup --- example/babel.config.js | 12 ++++- example/metro.config.js | 7 ++- patches/html-entities+2.5.3.patch | 8 --- patches/metro+0.84.4.patch | 31 +++++++++++ .../react-native++metro-runtime+0.84.4.patch | 36 +++++++++++++ src/MarkdownTextInput.tsx | 6 --- src/__tests__/parseExpensiMark.test.ts | 2 +- src/__tests__/singleLineInputFix.test.tsx | 2 +- src/__tests__/webParser.test.tsx | 2 +- src/index.tsx | 2 +- src/parse.ts | 37 +++++++++++++ src/parseExpensiMark.ts | 53 +------------------ src/rangeUtils.ts | 2 - 13 files changed, 126 insertions(+), 74 deletions(-) delete mode 100644 patches/html-entities+2.5.3.patch create mode 100644 patches/metro+0.84.4.patch create mode 100644 patches/react-native++metro-runtime+0.84.4.patch create mode 100644 src/parse.ts diff --git a/example/babel.config.js b/example/babel.config.js index 058e85775..b8ce3c69a 100644 --- a/example/babel.config.js +++ b/example/babel.config.js @@ -1,6 +1,16 @@ const path = require('path'); const pak = require('../package.json'); +/** @type {import('react-native-worklets/plugin').PluginOptions} */ +const workletsPluginOptions = { + bundleMode: true, + strictGlobal: true, + importForwarding: { + moduleNames: ['expensify-common', '@expensify/react-native-live-markdown'], + relativePaths: ['react-native-live-markdown/src/'], + }, +}; + module.exports = { presets: ['module:@react-native/babel-preset'], plugins: [ @@ -13,6 +23,6 @@ module.exports = { }, }, ], - 'react-native-worklets/plugin', + ['react-native-worklets/plugin', workletsPluginOptions], ], }; diff --git a/example/metro.config.js b/example/metro.config.js index 7fd57bd18..d5fd5eae9 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -1,4 +1,5 @@ const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); +const {bundleModeMetroConfig} = require('react-native-worklets/bundleMode'); const path = require('path'); const root = path.resolve(__dirname, '..'); @@ -22,4 +23,8 @@ const config = { }, }; -module.exports = mergeConfig(getDefaultConfig(__dirname), config); +module.exports = mergeConfig( + getDefaultConfig(__dirname), + bundleModeMetroConfig, + config, +); diff --git a/patches/html-entities+2.5.3.patch b/patches/html-entities+2.5.3.patch deleted file mode 100644 index 87a05d465..000000000 --- a/patches/html-entities+2.5.3.patch +++ /dev/null @@ -1,8 +0,0 @@ -diff --git a/node_modules/html-entities/lib/index.js b/node_modules/html-entities/lib/index.js -index bc910e7..ed54edf 100644 ---- a/node_modules/html-entities/lib/index.js -+++ b/node_modules/html-entities/lib/index.js -@@ -1,2 +1,3 @@ -+"worklet"; // this is necessary because HtmlEntities.decode is used in ExpensiMark from expensify-common which is called inside a worklet from react-native-live-markdown - "use strict";var __assign=this&&this.__assign||function(){__assign=Object.assign||function(t){for(var s,i=1,n=arguments.length;i'"&]/g,nonAscii:/[<>'"&\u0080-\uD7FF\uE000-\uFFFF\uDC00-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g,nonAsciiPrintable:/[<>'"&\x01-\x08\x11-\x15\x17-\x1F\x7f-\uD7FF\uE000-\uFFFF\uDC00-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g,nonAsciiPrintableOnly:/[\x01-\x08\x11-\x15\x17-\x1F\x7f-\uD7FF\uE000-\uFFFF\uDC00-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g,extensive:/[\x01-\x0c\x0e-\x1f\x21-\x2c\x2e-\x2f\x3a-\x40\x5b-\x60\x7b-\x7d\x7f-\uD7FF\uE000-\uFFFF\uDC00-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]?/g};var defaultEncodeOptions={mode:"specialChars",level:"all",numeric:"decimal"};function encode(text,_a){var _b=_a===void 0?defaultEncodeOptions:_a,_c=_b.mode,mode=_c===void 0?"specialChars":_c,_d=_b.numeric,numeric=_d===void 0?"decimal":_d,_e=_b.level,level=_e===void 0?"all":_e;if(!text){return""}var encodeRegExp=encodeRegExps[mode];var references=allNamedReferences[level].characters;var isHex=numeric==="hexadecimal";return text.replace(encodeRegExp,(function(input){var result=references[input];if(!result){var code=input.length>1?surrogate_pairs_1.getCodePoint(input,0):input.charCodeAt(0);result=(isHex?"&#x"+code.toString(16):"&#"+code)+";"}return result}))}exports.encode=encode;var defaultDecodeOptions={scope:"body",level:"all"};var strict=/&(?:#\d+|#[xX][\da-fA-F]+|[0-9a-zA-Z]+);/g;var attribute=/&(?:#\d+|#[xX][\da-fA-F]+|[0-9a-zA-Z]+)[;=]?/g;var baseDecodeRegExps={xml:{strict:strict,attribute:attribute,body:named_references_1.bodyRegExps.xml},html4:{strict:strict,attribute:attribute,body:named_references_1.bodyRegExps.html4},html5:{strict:strict,attribute:attribute,body:named_references_1.bodyRegExps.html5}};var decodeRegExps=__assign(__assign({},baseDecodeRegExps),{all:baseDecodeRegExps.html5});var fromCharCode=String.fromCharCode;var outOfBoundsChar=fromCharCode(65533);var defaultDecodeEntityOptions={level:"all"};function getDecodedEntity(entity,references,isAttribute,isStrict){var decodeResult=entity;var decodeEntityLastChar=entity[entity.length-1];if(isAttribute&&decodeEntityLastChar==="="){decodeResult=entity}else if(isStrict&&decodeEntityLastChar!==";"){decodeResult=entity}else{var decodeResultByReference=references[entity];if(decodeResultByReference){decodeResult=decodeResultByReference}else if(entity[0]==="&"&&entity[1]==="#"){var decodeSecondChar=entity[2];var decodeCode=decodeSecondChar=="x"||decodeSecondChar=="X"?parseInt(entity.substr(3),16):parseInt(entity.substr(2));decodeResult=decodeCode>=1114111?outOfBoundsChar:decodeCode>65535?surrogate_pairs_1.fromCodePoint(decodeCode):fromCharCode(numeric_unicode_map_1.numericUnicodeMap[decodeCode]||decodeCode)}}return decodeResult}function decodeEntity(entity,_a){var _b=(_a===void 0?defaultDecodeEntityOptions:_a).level,level=_b===void 0?"all":_b;if(!entity){return""}return getDecodedEntity(entity,allNamedReferences[level].entities,false,false)}exports.decodeEntity=decodeEntity;function decode(text,_a){var _b=_a===void 0?defaultDecodeOptions:_a,_c=_b.level,level=_c===void 0?"all":_c,_d=_b.scope,scope=_d===void 0?level==="xml"?"strict":"body":_d;if(!text){return""}var decodeRegExp=decodeRegExps[level][scope];var references=allNamedReferences[level].entities;var isAttribute=scope==="attribute";var isStrict=scope==="strict";return text.replace(decodeRegExp,(function(entity){return getDecodedEntity(entity,references,isAttribute,isStrict)}))}exports.decode=decode; - //# sourceMappingURL=./index.js.map diff --git a/patches/metro+0.84.4.patch b/patches/metro+0.84.4.patch new file mode 100644 index 000000000..7e1075d61 --- /dev/null +++ b/patches/metro+0.84.4.patch @@ -0,0 +1,31 @@ +diff --git a/node_modules/metro/src/node-haste/DependencyGraph.js b/node_modules/metro/src/node-haste/DependencyGraph.js +index 8222d8c..e36770d 100644 +--- a/node_modules/metro/src/node-haste/DependencyGraph.js ++++ b/node_modules/metro/src/node-haste/DependencyGraph.js +@@ -32,6 +32,11 @@ function getOrCreateMap(map, field) { + } + return subMap; + } ++ ++const workletsDirPath = _path.default.join( ++ "react-native-worklets", ++ ".worklets", ++); + class DependencyGraph extends _events.default { + #packageCache; + #dependencyPlugin; +@@ -197,6 +202,14 @@ class DependencyGraph extends _events.default { + return (0, _nullthrows.default)(this._fileSystem).getAllFiles(); + } + async getOrComputeSha1(mixedPath) { ++ if (mixedPath.includes(workletsDirPath)) { ++ const createHash = require("crypto").createHash; ++ return { ++ sha1: createHash("sha1") ++ .update(performance.now().toString()) ++ .digest("hex"), ++ }; ++ } + const result = await this._fileSystem.getOrComputeSha1(mixedPath); + if (!result || !result.sha1) { + throw new Error(`Failed to get the SHA-1 for: ${mixedPath}. diff --git a/patches/react-native++metro-runtime+0.84.4.patch b/patches/react-native++metro-runtime+0.84.4.patch new file mode 100644 index 000000000..60c099450 --- /dev/null +++ b/patches/react-native++metro-runtime+0.84.4.patch @@ -0,0 +1,36 @@ +diff --git a/node_modules/react-native/node_modules/metro-runtime/src/modules/HMRClient.js b/node_modules/react-native/node_modules/metro-runtime/src/modules/HMRClient.js +index 534ac87..5773985 100644 +--- a/node_modules/react-native/node_modules/metro-runtime/src/modules/HMRClient.js ++++ b/node_modules/react-native/node_modules/metro-runtime/src/modules/HMRClient.js +@@ -3,6 +3,9 @@ + const EventEmitter = require("./vendor/eventemitter3"); + const HEARTBEAT_INTERVAL_MS = 20_000; + const inject = ({ module: [id, code], sourceURL }) => { ++ if (global.__workletsModuleProxy?.propagateModuleUpdate) { ++ global.__workletsModuleProxy.propagateModuleUpdate(code, sourceURL); ++ } + if (global.globalEvalWithSourceUrl) { + global.globalEvalWithSourceUrl(code, sourceURL); + } else { +diff --git a/node_modules/react-native/node_modules/metro-runtime/src/polyfills/require.js b/node_modules/react-native/node_modules/metro-runtime/src/polyfills/require.js +index c46fb7c..2ba29ab 100644 +--- a/node_modules/react-native/node_modules/metro-runtime/src/polyfills/require.js ++++ b/node_modules/react-native/node_modules/metro-runtime/src/polyfills/require.js +@@ -334,14 +334,14 @@ function unknownModuleError(id) { + } + return Error(message); + } ++metroRequire.getModules = () => { ++ return modules; ++}; + if (__DEV__) { + metroRequire.Systrace = { + beginEvent: () => {}, + endEvent: () => {}, + }; +- metroRequire.getModules = () => { +- return modules; +- }; + var createHotReloadingObject = function () { + const hot = { + _acceptCallback: null, diff --git a/src/MarkdownTextInput.tsx b/src/MarkdownTextInput.tsx index 935b05548..016dc5d91 100644 --- a/src/MarkdownTextInput.tsx +++ b/src/MarkdownTextInput.tsx @@ -98,12 +98,6 @@ const MarkdownTextInput = React.forwardRef { return registerParser(props.parser); }, [props.parser]); diff --git a/src/__tests__/parseExpensiMark.test.ts b/src/__tests__/parseExpensiMark.test.ts index 1453fafb9..4d0b3479c 100644 --- a/src/__tests__/parseExpensiMark.test.ts +++ b/src/__tests__/parseExpensiMark.test.ts @@ -1,6 +1,6 @@ import {expect} from '@jest/globals'; import type {MarkdownRange} from '../commonTypes'; -import parseExpensiMark from '../parseExpensiMark'; +import parseExpensiMark from '../parse'; declare module 'expect' { interface Matchers { diff --git a/src/__tests__/singleLineInputFix.test.tsx b/src/__tests__/singleLineInputFix.test.tsx index 1b0c3b943..433568560 100644 --- a/src/__tests__/singleLineInputFix.test.tsx +++ b/src/__tests__/singleLineInputFix.test.tsx @@ -1,6 +1,6 @@ import {expect} from '@jest/globals'; import {parseRangesToHTMLNodes} from '../web/utils/parserUtils'; -import parseExpensiMark from '../parseExpensiMark'; +import parseExpensiMark from '../parse'; /** * Focused tests for the single-line input fix diff --git a/src/__tests__/webParser.test.tsx b/src/__tests__/webParser.test.tsx index fbc77fd0e..0154b6e35 100644 --- a/src/__tests__/webParser.test.tsx +++ b/src/__tests__/webParser.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import {expect} from '@jest/globals'; import {parseRangesToHTMLNodes} from '../web/utils/parserUtils'; -import parseExpensiMark from '../parseExpensiMark'; +import parseExpensiMark from '../parse'; declare module 'expect' { interface Matchers { diff --git a/src/index.tsx b/src/index.tsx index de1da2e3a..ed26e40d3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,4 @@ export {default as MarkdownTextInput, getWorkletRuntime} from './MarkdownTextInput'; export type {MarkdownTextInputProps, MarkdownStyle} from './MarkdownTextInput'; export type {MarkdownType, MarkdownRange} from './commonTypes'; -export {default as parseExpensiMark} from './parseExpensiMark'; +export {default as parseExpensiMark} from './parse'; diff --git a/src/parse.ts b/src/parse.ts new file mode 100644 index 000000000..8eb2ee19b --- /dev/null +++ b/src/parse.ts @@ -0,0 +1,37 @@ +import type {MarkdownRange} from './commonTypes'; +import {groupRanges, sortRanges, excludeRangeTypesFromFormatting, getRangesToExcludeFormatting} from './rangeUtils'; +import {parseMarkdownToHTML, parseHTMLToTokens, parseTokensToTree, parseTreeToTextAndRanges} from './parseExpensiMark'; + +const MAX_PARSABLE_LENGTH = 4000; + +function parseExpensiMark(markdown: string): MarkdownRange[] { + 'worklet'; + + if (markdown.length > MAX_PARSABLE_LENGTH) { + return []; + } + const html = parseMarkdownToHTML(markdown); + const tokens = parseHTMLToTokens(html); + const tree = parseTokensToTree(tokens); + const [text, ranges] = parseTreeToTextAndRanges(tree); + if (text !== markdown) { + console.error( + `[react-native-live-markdown] Parsing error: the processed text does not match the original Markdown input. This may be caused by incorrect parsing functions or invalid input Markdown.\nProcessed input: '${JSON.stringify( + text, + )}'\nOriginal input: '${JSON.stringify(markdown)}'`, + ); + return []; + } + let markdownRanges = sortRanges(ranges); + + // Prevent italic, bold and strikethrough formatting inside emojis and inline code blocks + const rangesToExclude = getRangesToExcludeFormatting(markdownRanges); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'italic', rangesToExclude); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'bold', rangesToExclude); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'strikethrough', rangesToExclude); + + const groupedRanges = groupRanges(markdownRanges); + return groupedRanges; +} + +export default parseExpensiMark; diff --git a/src/parseExpensiMark.ts b/src/parseExpensiMark.ts index ebb462d51..b620f6252 100644 --- a/src/parseExpensiMark.ts +++ b/src/parseExpensiMark.ts @@ -1,30 +1,7 @@ -'worklet'; - -import {Platform} from 'react-native'; import {ExpensiMark} from 'expensify-common'; import {unescapeText} from 'expensify-common/utils'; -import {decode} from 'html-entities'; -import type {WorkletFunction} from 'react-native-worklets'; -import {groupRanges, sortRanges, excludeRangeTypesFromFormatting, getRangesToExcludeFormatting} from './rangeUtils'; import type {MarkdownRange, MarkdownType} from './commonTypes'; -function isWeb() { - return Platform.OS === 'web'; -} - -function isJest() { - return !!global.process.env.JEST_WORKER_ID; -} - -// eslint-disable-next-line no-underscore-dangle -if (__DEV__ && !isWeb() && !isJest() && (decode as WorkletFunction).__workletHash === undefined) { - throw new Error( - "[react-native-live-markdown] `parseExpensiMark` requires `html-entities` package to be workletized. Please add `'worklet';` directive at the top of `node_modules/html-entities/lib/index.js` using patch-package. Make sure you've installed `html-entities` version 2.5.3 exactly as otherwise there is no `lib/` directory.", - ); -} - -const MAX_PARSABLE_LENGTH = 4000; - type Token = ['TEXT' | 'HTML', string]; type StackItem = {tag: string; children: Array}; @@ -238,32 +215,4 @@ function parseTreeToTextAndRanges(tree: StackItem): [string, MarkdownRange[]] { return [text, ranges]; } -function parseExpensiMark(markdown: string): MarkdownRange[] { - if (markdown.length > MAX_PARSABLE_LENGTH) { - return []; - } - const html = parseMarkdownToHTML(markdown); - const tokens = parseHTMLToTokens(html); - const tree = parseTokensToTree(tokens); - const [text, ranges] = parseTreeToTextAndRanges(tree); - if (text !== markdown) { - console.error( - `[react-native-live-markdown] Parsing error: the processed text does not match the original Markdown input. This may be caused by incorrect parsing functions or invalid input Markdown.\nProcessed input: '${JSON.stringify( - text, - )}'\nOriginal input: '${JSON.stringify(markdown)}'`, - ); - return []; - } - let markdownRanges = sortRanges(ranges); - - // Prevent italic, bold and strikethrough formatting inside emojis and inline code blocks - const rangesToExclude = getRangesToExcludeFormatting(markdownRanges); - markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'italic', rangesToExclude); - markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'bold', rangesToExclude); - markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'strikethrough', rangesToExclude); - - const groupedRanges = groupRanges(markdownRanges); - return groupedRanges; -} - -export default parseExpensiMark; +export {parseMarkdownToHTML, parseHTMLToTokens, parseTokensToTree, parseTreeToTextAndRanges}; diff --git a/src/rangeUtils.ts b/src/rangeUtils.ts index dc4dabea2..53e7df9ad 100644 --- a/src/rangeUtils.ts +++ b/src/rangeUtils.ts @@ -1,5 +1,3 @@ -'worklet'; - import type {MarkdownRange, MarkdownType} from './commonTypes'; // getTagPriority returns a priority for a tag, higher priority means the tag should be processed first From e534f1d299658d168592b58827182a2e731448b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20=C5=BBelawski?= Date: Fri, 10 Jul 2026 11:39:48 +0200 Subject: [PATCH 2/2] chore: smaller patch --- .../react-native++metro-runtime+0.84.4.patch | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/patches/react-native++metro-runtime+0.84.4.patch b/patches/react-native++metro-runtime+0.84.4.patch index 60c099450..903b01055 100644 --- a/patches/react-native++metro-runtime+0.84.4.patch +++ b/patches/react-native++metro-runtime+0.84.4.patch @@ -12,25 +12,3 @@ index 534ac87..5773985 100644 if (global.globalEvalWithSourceUrl) { global.globalEvalWithSourceUrl(code, sourceURL); } else { -diff --git a/node_modules/react-native/node_modules/metro-runtime/src/polyfills/require.js b/node_modules/react-native/node_modules/metro-runtime/src/polyfills/require.js -index c46fb7c..2ba29ab 100644 ---- a/node_modules/react-native/node_modules/metro-runtime/src/polyfills/require.js -+++ b/node_modules/react-native/node_modules/metro-runtime/src/polyfills/require.js -@@ -334,14 +334,14 @@ function unknownModuleError(id) { - } - return Error(message); - } -+metroRequire.getModules = () => { -+ return modules; -+}; - if (__DEV__) { - metroRequire.Systrace = { - beginEvent: () => {}, - endEvent: () => {}, - }; -- metroRequire.getModules = () => { -- return modules; -- }; - var createHotReloadingObject = function () { - const hot = { - _acceptCallback: null,