diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dc0536..70679b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,11 +5,20 @@ on: branches: [main] pull_request: +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 20 @@ -34,12 +43,7 @@ jobs: # gate. While a second, pure-TypeScript parser existed, a runner with no # C++ toolchain could still prove the library and merely skip the native # measurements, so `--if-available` cost only numbers. Now a runner that - # skips this build runs *zero* markdown-parsing checks: jest quietly - # reports ten of twenty-six suites as skipped, the conformance run has - # nothing to score, and the job goes green having tested no parse. A - # missing compiler on ubuntu-latest is not a tolerable runner property, - # it is a broken job, so it fails here where the cause is named rather - # than passing silently downstream. + # skips this build runs no parsing suite: `describeNative` skips without the addon. # # This must come BEFORE `npm test`: the src/engine/native/ suites load # build/selectable-markdown.node, and jest picks them up automatically. @@ -61,7 +65,19 @@ jobs: # Gates: unit suites, the every-character-prefix streaming oracle, # copy-fidelity properties, and the tail-repair corpus. - run: npm test - - run: npm run build + # Per-stage budgets are cliff detectors at 25-60x the measured medians, not targets. + - run: >- + npm run bench:pathological -- + --require-engine + --budget-parse 750 --budget-repair 750 --budget-segment 200 --budget-project 750 + # This transcript never anchors, so every append re-reads the whole text. + - run: >- + npm run bench:streaming -- + --transcript conformance/fixtures/transcript-giant-list.json + --require-engine --repeat 1 + --budget-chunk 20 --budget-finalize 50 + # A deterministic count, not a clock: `cached` growth over 1.25x per doubling fails. + - run: npm run bench:projection -- --require-engine # Gate: the packed tarball is what a consumer actually installs, and # dist/ is never committed — this proves it still carries a working # entrypoint, the podspec, and the native sources. @@ -115,6 +131,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 20 @@ -133,16 +151,20 @@ jobs: else sudo apt-get update && sudo apt-get install -y libboost-dev fi - # Gate: every .cpp this package ships to a consumer's compiler is - # compiled against the genuine React Native 0.75.4 renderer headers — on - # BOTH platform header sets on the macOS leg, on the Android set here on - # ubuntu — including the Android include-order seam that decides whether - # runs measure at all. - run: npm run check:fabric-cpp - # Gate: the checker can still fail. Five deliberately broken translation - # units must each be rejected, and rejected for the stated reason — - # against the iOS headers on macOS, the Android headers on ubuntu. - run: npm run check:fabric-cpp:selftest + # The only job that compiles platform/cpp; `find` because a shell glob does not descend. + - name: Compile the markdown engine at the podspec's C++ standard + run: | + if [ "$RUNNER_OS" = "macOS" ]; then platform=ios; else platform=android; fi + files=$(find platform/cpp -path platform/cpp/vendor -prune -o -name '*.cpp' -print | sort) + if [ -z "$files" ]; then + echo "no .cpp found under platform/cpp — this gate would pass having compiled nothing" + exit 1 + fi + echo "$files" + # Unquoted on purpose: one argument per path. No path here has a space. + npm run check:fabric-cpp -- --syntax-only --platform "$platform" $files swift: name: Swift (real RN headers + iOS SDK) @@ -155,6 +177,8 @@ jobs: RNSM_REQUIRE_SWIFT: '1' steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 20 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8988311..23e59f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,19 +6,24 @@ on: push: tags: ['v*'] +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: - release: + preflight: + name: Preflight (tag, lockfile, changelog) runs-on: ubuntu-latest - permissions: - contents: write # attach the packed tarball to the GitHub release - id-token: write # npm publish --provenance steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 20 - cache: npm - registry-url: https://registry.npmjs.org # The podspec reads its version out of package.json, so a tag that # disagrees with it ships a pod CocoaPods cannot resolve. @@ -30,28 +35,92 @@ jobs: exit 1 fi + # `npm ci` never checks the lock's root entry against package.json; only this does. + - name: Check package-lock.json mirrors the manifest + run: node scripts/check-lock-sync.mjs + + - name: Check CHANGELOG.md has a section for this tag + run: node scripts/changelog-section.mjs "$GITHUB_REF_NAME" > /dev/null + + - name: Check no BREAKING change ships under an already-tagged version + run: node scripts/check-unreleased-breaking.mjs --tag "$GITHUB_REF_NAME" + + # A tag can be pushed at any commit, so CI having passed proves nothing about it. + macos-gates: + name: macOS gates (Swift + the iOS header set) + needs: preflight + runs-on: macos-latest + env: + RNSM_REQUIRE_SWIFT: '1' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + # Boost is the only dependency check-fabric-cpp.mjs does not fetch itself. + - run: brew install boost + - run: npm run check:fabric-cpp + - run: npm run check:fabric-cpp:selftest + - name: Compile the markdown engine (iOS header set) + run: | + files=$(find platform/cpp -path platform/cpp/vendor -prune -o -name '*.cpp' -print | sort) + if [ -z "$files" ]; then + echo "no .cpp found under platform/cpp — this gate would pass having compiled nothing" + exit 1 + fi + echo "$files" + npm run check:fabric-cpp -- --syntax-only --platform ios $files + - run: npm run check:swift + - run: npm run check:swift:selftest + + release: + needs: macos-gates + runs-on: ubuntu-latest + permissions: + contents: write # attach the packed tarball to the GitHub release + id-token: write # npm publish --provenance + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + registry-url: https://registry.npmjs.org + - run: npm ci + # pipefail: otherwise the status is tee's, and a failed extract publishes empty notes. + - name: Extract the release notes for this tag + run: | + set -o pipefail + node scripts/changelog-section.mjs "$GITHUB_REF_NAME" | tee "${RUNNER_TEMP:-/tmp}/release-notes.md" + # THE SAME GATES CI RUNS, NOT A SUBSET. A tag can be pushed at any # commit — including one CI never saw, or one CI saw fail — and this # workflow is the last thing between the code and the npm registry. # Running only `npm test` here meant a release could ship a type error, # a codegen shape change that silently disables the component, or C++ # that no longer compiles against React Native's headers. - # - # Everything below runs on this ubuntu runner. Two gates need macOS and - # are deliberately not repeated here, because adding a job the publish - # depends on doubles the time between pushing a tag and having an - # artifact: the Swift gate (Xcode), and the iOS half of check:fabric-cpp - # (React Native's iOS renderer headers need an Apple SDK, so on this - # runner the script skips that pass out loud and gates the Android - # pass). CI runs both on macOS on every push to main and every pull - # request, which is where those regressions should be caught. - run: npm run typecheck - run: npm run check:codegen - run: sudo apt-get update && sudo apt-get install -y libboost-dev - run: npm run check:fabric-cpp - run: npm run check:fabric-cpp:selftest + - name: Compile the markdown engine (Android header set) + run: | + files=$(find platform/cpp -path platform/cpp/vendor -prune -o -name '*.cpp' -print | sort) + if [ -z "$files" ]; then + echo "no .cpp found under platform/cpp — this gate would pass having compiled nothing" + exit 1 + fi + echo "$files" + npm run check:fabric-cpp -- --syntax-only --platform android $files # HARD GATE, AND IT MUST COME BEFORE `npm test` — the same reasoning as # ci.yml. `describeNative` in src/engine/native/__tests__/support.ts is @@ -64,26 +133,26 @@ jobs: - run: node scripts/build-node-addon.mjs - run: npm test - # dist/ is never committed: `npm pack` runs prepare/prepack, which build - # it, and verify:pack proves the resulting tarball is consumable. + # No build step: `npm pack` runs `prepare`, which builds dist/. - run: npm run verify:pack - - run: npm pack - - # The prebuilt tarball is the fast pin for consumers who cannot use the - # registry — installing it needs no toolchain and no build step, unlike - # a `github:` dependency. - - name: Attach the tarball to the release - env: - GH_TOKEN: ${{ github.token }} + - run: >- + npm run bench:pathological -- + --require-engine + --budget-parse 750 --budget-repair 750 --budget-segment 200 --budget-project 750 + - run: >- + npm run bench:streaming -- + --transcript conformance/fixtures/transcript-giant-list.json + --require-engine --repeat 1 + --budget-chunk 20 --budget-finalize 50 + - run: npm run bench:projection -- --require-engine + - name: Pack the release artifact run: | - if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then - gh release upload "$GITHUB_REF_NAME" ./*.tgz --clobber - else - gh release create "$GITHUB_REF_NAME" ./*.tgz --generate-notes --verify-tag - fi + npm pack + version=$(node -p "require('./package.json').version") + echo "RELEASE_TARBALL=react-native-selectable-markdown-${version}.tgz" >> "$GITHUB_ENV" # Skipped, not failed, when no NPM_TOKEN secret is configured — the - # GitHub release above is still a complete, installable artifact. + # GitHub release below is still a complete, installable artifact. - name: Publish to npm env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -92,4 +161,19 @@ jobs: echo "NPM_TOKEN is not set — skipping npm publish." exit 0 fi - npm publish --provenance --access public + npm publish "$RELEASE_TARBALL" --provenance --access public + + # The prebuilt tarball is the fast pin for consumers who cannot use the + # registry — installing it needs no toolchain and no build step, unlike + # a `github:` dependency. + - name: Attach the tarball to the release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" "$RELEASE_TARBALL" --clobber + gh release edit "$GITHUB_REF_NAME" --notes-file "${RUNNER_TEMP:-/tmp}/release-notes.md" + else + gh release create "$GITHUB_REF_NAME" "$RELEASE_TARBALL" \ + --notes-file "${RUNNER_TEMP:-/tmp}/release-notes.md" --verify-tag + fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ac34f27 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,78 @@ +# Changelog + +Versions follow [semver](https://semver.org/); pre-1.0, breaking changes land in a minor bump. + +## [Unreleased] + +- BREAKING: the package entry exports an explicit list of 191 names. Ten internals left the root and are imported by path instead: `decodeFlatBuffer`, `NativeProtocolError`, `applySmartPunctuation`, `PROTOCOL_VERSION`, `findHostBinding`, `NativeHostBinding` and `__linkNativeEngine` from `dist/engine/native`; `getOrCreateSession` from `dist/agui/useAgUiSession`; `embedContentFor` from `dist/selection/runs`; `isUriLikeLabel` from `dist/stream/repair`. +- Deprecated: the exported `classifyBlock` function is now `classifyTopLevelBlock`. The old name stays as a deprecated alias until a later minor. The `classifyBlock` prop and the `ClassifyBlock` type are unchanged. +- Selection: imperative API on ``. A `ref` (`SelectableMarkdownHandle`) exposes `getSelection()`, `clearSelection()` and `setSelection(span)`; `onSelectionChange` reports the live selection as `{ span, plain }` or `null`. +- Selection: `RunHostHandle` (`clearSelection`, `setSelection`) and the exported `mapSourceToRunRange` for consumers driving `RunHost` directly. +- Selection: `exclusiveSelection` prop (default `true`) on `` and `RunHost`; `false` opts out of the one-active-selection coordination. +- Selection: prose merging stops at `DEFAULT_MAX_RUN_CHARS` (8000). `maxRunChars` is a `` prop and a `segmentRuns` option; `Infinity` restores the old behaviour. +- Selection: `classifyBlock` results are memoized on block identity, so the callback must be pure and referentially stable. +- Selection: `ProjectedRun.extents` (`ProjectedExtent`) and `CopyContext.classifyBlock`. +- Selection: copy-markdown keeps the syntax of a wholly selected heading, list, list item, blockquote, code block, table, emphasis, strong span or link. +- Selection: an escape, entity, smart quote or ellipsis no longer pins its whole text node; indented code blocks no longer map one indent-width to the left. +- Streaming: `repairTail` takes a carry-forward `RepairScan` (`RepairResult.scan`) so successive calls resume the inline scan. +- Streaming: a release never cuts inside a grapheme cluster (flags, ZWJ sequences, combining marks). +- BREAKING: images default to `images="embed"`. They reserve a 280-point `spacing.imageWidth` box, appear once their run settles, and retain the run's `onSelectionCopy` behavior. Use `images="standalone"` to restore the previous rendering and selection scope. The built-in claim accepts only a sole image in a top-level paragraph; inline images and images in lists, headings, quotes, and tables retain standalone layout. `withImageEmbeds` and `ImageMode` are exported. +- View: incremental run projection for consumers: `createRunProjectionCache`, `RunProjectionCache`, `ProjectRunOptions.previous`. +- View: `selectionActions` entries may be `{ id, title }`; `SelectionCopyEvent.action` and `SelectionActionEvent.action` widen to `SelectionActionId` and every non-empty id is reported verbatim; `handleSelectionAction` gains `ctx.actions`. +- View: `RenderContext.selectable` (absent means selectable) and `RenderContext.linkPrefixes`; `openUrl(href, allowedPrefixes?)` sanitizes and re-checks the href against `urlPolicy.linkPrefixes`. +- View: `MarkAttribute` returns the narrower `RunMarkStyle`; `colors.quoteBar` is gone from the default themes (still honoured as a deprecated input, read `theme.quote.barColor` instead). +- View: DEV warnings for an untitled unknown `selectionActions` id, an action id that was never offered, and `blockedLinks: 'node'` paired with only a `link` renderer override. +- View: an embed claim with an infinite `width` or `height` is rejected. +- Engine: `sanitizeUrl`, `isUrlAllowed` and `isNativeEnginePermanentlyRefused` are exported; `NativeModules.SelectableMarkdown.install()` returns `'installed' | 'unavailable' | 'refused'` and `isNativeEngineAvailable()` no longer re-crosses the bridge after a permanent refusal. +- Engine: semantic fields on the run wire (`role`, `roleLevel`, `roleRow`, `roleRowCount`, `roleColumn`, `roleColumnCount`), separate from styling. +- iOS and Android: headings, list items and table cells inside a merged run are announced to VoiceOver and TalkBack. +- iOS: the blockquote bar sits on the leading edge for right-to-left paragraphs. +- Android: selection menu strings ship in `res/values/strings.xml` (`selectable_markdown_copy_text`, `selectable_markdown_copy_markdown`) and can be overridden or translated. +- Packaging: `exports` map (root, `./dist`, `./dist/*`, `./src/*`, `./package.json`, `./react-native.config.js`), `sideEffects: false`, an ES module build in `dist/esm` with `import`/`require` conditions, and `dist/view/SelectableRunHostNativeComponent.d.ts` beside the shim. +- Packaging: `./engine` and `./stream` subpath entries for headless consumers (plain Node, a consumer's jest) that cannot load the root because it imports `react-native`; `./node` resolves the Node addon loader, and `native/node` plus `scripts/build-node-addon.mjs` ship in the tarball so a consumer can build the engine for its tests. +- Release: notes come from this file (`scripts/changelog-section.mjs`); `scripts/check-unreleased-breaking.mjs` refuses a pending breaking change under an already-tagged version; `scripts/check-lock-sync.mjs` checks the lockfile mirrors the manifest. +- Benches and CI: `bench:pathological` takes per-stage budgets and `--require-engine`; `bench:projection` and `bench:streaming` are CI gates. Streaming budgets cover per-chunk and finalization time on the giant-list transcript. Correctness tests use an isolated packaging build; builds clear stale `dist` output, and tarballs exclude Android build caches and native binaries. + +- BREAKING: URL prefixes now match paths case-sensitively, reject traversal above a path prefix, and require an authority boundary after a host-only prefix. For example, `/photos/` no longer matches `/Photos/`, and `https://good.com` no longer admits `https://good.com.evil.com` or userinfo on another host. +- BREAKING: React Native's Jest resolver follows the new `react-native` export condition into TypeScript source. Add this package to the Jest transform allowlist shown in README, including when using the headless `engine` and `stream` entries under that preset. +- Streaming: `dispose()` cancels pending work, drops buffered text and subscribers, and resolves waiting drains. `drained()` rejects after exhausted engine retries; new buffered or rewritten input starts a fresh attempt. Subscriber exceptions no longer strand an emptied drain. +- Streaming: URI-like label hiding now requires the documented URI shape; spoiler and HTML repair recognize more constructs. Link-reference definitions outside code fences pin `settledUntil` at zero. Word-boundary smoothing falls back to character progress after 32 units of overdraw. +- Streaming: fence indentation and backslashes inside code spans no longer append visible repair characters; long regional-indicator runs preserve flag boundaries. Less-than prose without a closer scans linearly. +- Selection: source/display restoration retains complete list markers; growing unsettled blocks and standalone tails preserve host identity. Independent classifiers keep separate caches, and short selection mappings skip unrelated pieces and extents. +- View: renderer functions mount as components so they may use hooks. Keep renderer identities stable; an inline function remounts its component. Nesting beyond 64 renderer levels falls back to flat text. Embedded-image alt text remains on the overlay, whose assistive reading order can differ from surrounding host text; use standalone images when that order is required. +- AG-UI: session hooks suspend timers on effect cleanup and resume retained sessions on reconnection and finalize outgoing message sessions as aborted. Run bindings support per-row storage, authoritative content, paced rewrites, late-row grace, run identities and failure dispositions; rebinding preserves an outstanding run-end drain. +- Engine: email autolinks are decoded, hrefs are decoded once, and malformed native event streams throw instead of returning partial documents. Spoilers remain active when smart punctuation changes surrounding text. Empty container children exclude their parent marker, and blank-only fences report their closing fence. +- iOS: native runs follow Dynamic Type; code/table padding at a run edge contributes to host height. Accessibility follows document order, fully covered runs expose a labeled selection control, recycled hosts refresh inset layout, and mixed-direction quotes resolve direction per paragraph. +- Android: semantic-only updates preserve selection. Layout caches track resolved typefaces, accessibility work waits until enabled, and SDK defaults are 36/24/36 for compile/minimum/target. Development checks use React Native 0.82.1 and React 19.1.1. + +- Selection: the first embed preserves its host; host removal clears selection. With `exclusiveSelection={false}`, clearing the latest selection reports the most recently selected run that is still live. Blocked-link labels with entities or markup no longer restore the blocked destination when copied. +- Streaming: repeated run IDs work without `onRunStarted`; rebinding retains buffered routing even between arrivals. Evicted sessions cancel their timers. Run-end notifications release final Unicode clusters without the idle delay; snapshot block arrays no longer expose internal storage. `suspend()` and `resume()` stop and restart buffered work without losing input. +- Engine: `
` remains a hard break under `html: 'strip'`. Escaped backslashes before spoiler delimiters are counted by parity. Empty projected block groups render standalone so their decoration remains visible. +- View: standalone renderers use automatic text alignment and leading-edge spacing for RTL content. Images with alt text expose an image accessibility role. +- Packaging: type declarations take precedence over the React Native source condition. Built deep imports share source modules with the root under React Native; explicit `dist/esm` and extensionless source paths resolve correctly. +- Public exports added since 0.11.0: `VisitSignal`, `withOptions`, `isNativeEnginePermanentlyRefused`, `sanitizeUrl`, `isUrlAllowed`, `RepairScan`, `classifyTopLevelBlock`, `DEFAULT_MAX_RUN_CHARS`, `ProjectedExtent`, `PreviousProjection`, `BufferedSessionSink`, `MessageBindingOptions`, `UseAgUiSessionInit`, `UseAgUiSessionOptions`, `MAX_RENDER_DEPTH`, `ImageMode`, `withImageEmbeds`, `RunSemanticRole`, `RunMarkStyle`, `isReservableEmbedSize`, `RunProjectionCache`, `createRunProjectionCache`, `RunTextRange`, `mapSourceToRunRange`, `SelectionChangeEvent`, `RunHostHandle`, `RunHostAccessibilityProps`, `SelectionActionId`, `SelectionActionSpec`, `SelectionActionInput`, `selectionDisplayText`, `SelectableMarkdownAccessibilityProps`, `SelectableMarkdownSelection`, and `SelectableMarkdownHandle`. +- iOS: splices compare immutable input snapshots and ignore run-edge inset metadata; collapsed selections report one empty range. Code boxes resolve writing direction per paragraph, and measured heights round after edge padding. +- Android: inline embed height affects only its own line; keyboard focus visits links while screen-reader navigation retains semantic nodes. Links use React Native's localized role description, and measured and displayed text share elegant font metrics. +- Release: npm publishes the exact tarball attached to GitHub, before release creation. The local release guard checks the proposed tag like CI does. + +## [0.11.0] — 2026-09-01 + +- Embeds restored, Fabric-only: the `embed` prop takes an `EmbedRenderer`, a claimed node flows through its run as one placeholder, the host reserves the declared size and `onEmbedLayout` reports the rect. +- `RunEmbed` and the embed projection helpers are public again; `RunHost` gains the `embeds` prop and `onEmbedLayout` event. +- The codegen spec carries `embeds` and `onEmbedLayout`; `npm run check:codegen` asserts both on iOS and Android. +- Native embed support on both platforms: placeholder-aware attributed text and rect reporting on iOS; `RunEmbeds`, `EmbedLayoutEvent` and an embed-keyed layout cache on Android. +- Selection tests cover embedded runs (projection oracle, run segmentation, copy fidelity). + +## [0.10.0] — 2026-09-01 + +- BREAKING: peer floor raised to `react-native >= 0.82`; the package targets bridgeless Fabric only. +- BREAKING: the old-architecture (Paper) selection host is removed. +- BREAKING: the `` fallback is removed; `RunHost` throws where the native component is not registered. +- BREAKING: embeds removed (restored in 0.11.0). +- BREAKING: `StreamSessionInit.onUpdate` removed; use `session.subscribe(listener)`. +- `` renders the session's snapshot; the session owns its parse context. +- Tests are excluded from the published tarball. + +[Unreleased]: https://github.com/superpowerdotcom/react-native-selectable-markdown/compare/v0.11.0...HEAD +[0.11.0]: https://github.com/superpowerdotcom/react-native-selectable-markdown/releases/tag/v0.11.0 +[0.10.0]: https://github.com/superpowerdotcom/react-native-selectable-markdown/releases/tag/v0.10.0 diff --git a/README.md b/README.md index ec5a53c..d787134 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # react-native-selectable-markdown -Markdown rendering for React Native, built for streamed LLM output, with selection that works like a document. Adjacent paragraphs, headings, lists, code blocks and tables merge into one selectable run, and every selected character maps back to an exact UTF-16 range of the source. Copy gives you the markdown itself. +Markdown rendering for React Native, built for streamed LLM output, with selection that works like a document. Adjacent paragraphs, headings, lists, code blocks and tables merge into one selectable run, and every selected character maps back to an exact UTF-16 range of the source. Copy gives you the markdown itself: the exact source slice, syntax included for any construct the selection covers in full. Parsing is done by [md4c](https://github.com/mity/md4c), compiled into your app. There is no JavaScript parser, so nothing renders until the app is rebuilt with the native module linked (see [Native setup](#native-setup)). You can swap in your own parser through the `Engine` interface. MIT licensed. @@ -14,8 +14,9 @@ The fix for all three is a `SourceSpan` on every parsed node. Selection, copy, m ## Features -- Selection across adjacent blocks. Code blocks, tables and rules flow through runs too, with their boxes and rules painted as decorations under the text. A run ends at an image, a spoiler, or a block you mark standalone. -- Copy Text and Copy Markdown in the selection menu. The second is an exact source slice. +- Selection across adjacent blocks. Code blocks, tables and rules flow through runs too, with their boxes and rules painted as decorations under the text. Images flow as embeds: the host reserves space on the line and the image renderer draws over it. A run ends at a whole block — the list or the table, not just the construct — that holds a spoiler, an image nothing claimed, or a node you mark standalone. Merging also stops at `maxRunChars` (a prop; 8000 source characters by default, `Infinity` opts out), which only ever breaks between top-level blocks. +- Copy Text and Copy Markdown in the selection menu. The second is an exact source slice. Both are retitleable from JS, and your own menu items report back the same way. +- An imperative selection API. `onSelectionChange` reports the live selection as an exact source span, including when it goes away, and a ref gives you `getSelection()`, `clearSelection()` and `setSelection(span)`. - Streaming without artifacts. The unsettled tail is repaired before parsing, settled blocks keep referential identity, and plain-prose deltas skip the parser. Per-frame coalescing and a typewriter smoother are opt-in. - Safe defaults for model output. Every non-CommonMark extension is opt-in, HTML is stripped, URL schemes are allowlisted. - 651/652 on the CommonMark 0.31.2 spec suite, run in CI, plus a streaming oracle that checks every prefix of every fixture against a fresh parse. @@ -27,7 +28,7 @@ The fix for all three is a `SourceSpan` on every parsed node. Selection, copy, m npm install react-native-selectable-markdown ``` -Other ways in: a pinned commit (`npm install github:superpowerdotcom/react-native-selectable-markdown#`, rebuilds on every install), a tarball from the [releases page](https://github.com/superpowerdotcom/react-native-selectable-markdown/releases) (prebuilt), or a local checkout (`npm install file:../react-native-selectable-markdown`; `file:` deps are symlinked, so run `npm run build -- --watch` in the checkout yourself). `dist/` is never committed; `prepare` builds it for the npm and git paths. +Other ways in: a pinned commit (`npm install github:superpowerdotcom/react-native-selectable-markdown#`, rebuilds on every install), a tarball from the [releases page](https://github.com/superpowerdotcom/react-native-selectable-markdown/releases) (prebuilt), or a local checkout (`npm install file:../react-native-selectable-markdown`; `file:` deps are symlinked, so run `npm run build:watch` in the checkout yourself — Metro takes the `react-native` condition straight to `src/`, so the watch build only matters for bundlers that resolve `main`). `dist/` is never committed; `prepare` builds it for the npm and git paths. What changed per version is in [CHANGELOG.md](CHANGELOG.md). ### Native setup @@ -45,9 +46,9 @@ if (!installNativeEngine()) { } ``` -Without the module, `parseDocument` throws on the first non-empty document, and so does `` during render; wrap it in an error boundary if that is a state your app can be in. `RunHost`, the selection host, throws where its Fabric component is not registered (Expo Go, web, test renderers). It ships as a Fabric component only, matching the `react-native >= 0.82` peer floor. `use_frameworks! :linkage => :dynamic` disables registration of all third-party Fabric components with no error, this one included. +Without the module, `parseDocument` throws on the first non-empty document, and so does `` during render; wrap it in an error boundary if that is a state your app can be in. `RunHost`, the selection host, throws where its Fabric component is not registered (Expo Go, web, test renderers). It ships as a Fabric component only, matching the `react-native >= 0.82` peer floor. Registration comes from this package's `codegenConfig.ios.componentProvider`: it puts a `SelectableRunHost` entry in the app's generated `RCTThirdPartyComponentsProvider.mm`, and the component is looked up by name from there — which is what the throw message tells you to check. -Where native code cannot run at all, everything above the parser is plain TypeScript and still works. Pass your own `Engine`; its one hard requirement is a `SourceSpan` on every node, with UTF-16 offsets into exactly the source it was handed. +Where native code cannot run at all, everything above the parser is plain TypeScript and still works. Pass your own `Engine`; its one hard requirement is a `SourceSpan` on every node, with UTF-16 offsets into exactly the source it was handed. Streaming is the one layer that assumes CommonMark on top of that: tail repair appends virtual closers and suppresses ambiguous tail lines before the engine sees them, and the parse-free fast path keys on CommonMark's construct characters, so a delta that only opens syntax of your own renders as stale text until the next construct character arrives. ## Quick start @@ -71,7 +72,9 @@ export function Message({ markdown }: { markdown: string }) { } ``` -`selectionActions` and `onSelectionCopy` work as a pair. Setting one without the other yields an empty custom menu. +`selectionActions` needs `onSelectionCopy`: with no handler the custom menu is empty rather than one item short, because an item that reports nowhere is not offered (DEV warns). The reverse is not true — a handler on its own is the common case and gets both default items. An entry may also be `{ id, title }`, which is the one way to localise both platforms from JS; any id beyond the two built-ins is your own action and must carry a title, and it arrives at `onSelectionCopy` with the same `plain`, `markdown` and `span`. A `session` supersedes `source`, which is then never parsed (DEV warns). + +For a toolbar of your own, `onSelectionChange` reports `{ span, plain }` as the user drags and `null` when the selection goes away — the half `onSelectionCopy` cannot give you, since it fires only after a menu item is tapped. A `ref` typed `SelectableMarkdownHandle` adds `getSelection()`, `clearSelection()` and `setSelection(span)`. `setSelection` returns `false` for two kinds of refusal: no run shows the span (past the end of the document, a standalone block, a span of pure markup like a fence or a `# `, a run not mounted yet), or the run that shows it cannot take a selection right now (Android's unsettled streaming tail, a run rendered `selectable={false}`, a binary built against a native spec older than the selection commands). It can select wider than asked when the span lands inside an entity, an image's alt text or an embed placeholder, and it presents no menu and issues no scroll — there is no scroll-to-span, though it does take focus, which a scrolling ancestor is entitled to react to. Full contract in [docs/SELECTION.md](docs/SELECTION.md). ### Streaming @@ -86,7 +89,7 @@ session.finalize('end'); // or 'aborted' | 'failed'; idempotent ; ``` -`append` parses synchronously. For tokens that arrive faster than frames, `appendBuffered(delta)` coalesces them into one flush per frame. `flushBuffered()` drains now, and any synchronous call (`append`, `replace`, `finalize`) drains first. +`append` parses synchronously. For tokens that arrive faster than frames, `appendBuffered(delta)` coalesces them into one flush per frame. `flushBuffered()` drains now, and any synchronous call (`append`, `replace`, `finalize`) drains first. `dispose()` cancels the scheduled flush and the idle drain, drops the un-appended tail and clears subscribers — call it for a session dropped before its stream ends, after `flushBuffered()` if the tail should be kept. ```ts new StreamSession({ @@ -98,7 +101,7 @@ new StreamSession({ }); ``` -A `Smoother` decides how many UTF-16 units each flush releases, and the session keeps flushing until the buffer drains. `createSmoother({ charsPerSecond, boundary, maxLagChars })` is a fixed rate. `createAdaptiveSmoother()` is what we use for live LLM streams: it tracks the arrival rate, trails the head by a target lag, and drains against a bounded deadline at run end (call `session.notifyRunFinalized()`; the ag-ui binding does this for you). Await `session.drained()` before `finalize` so the tail finishes typing. `session.rewrite(full)` edits text the reader has not seen yet without interrupting the reveal. Details in [docs/STREAMING.md](docs/STREAMING.md). +A `Smoother` decides how many UTF-16 units each flush releases, and the session keeps flushing until the buffer drains. Only scheduled `appendBuffered` flushes are smoothed: `append`, `flushBuffered`, `replace` and `finalize` drain synchronously and release everything, so a session driven by `append` alone never consults the smoother and `drained()` resolves immediately. `createSmoother({ charsPerSecond, boundary, maxLagChars })` is a fixed rate; `boundary: 'word'` needs whitespace to cut at, so text written without spaces (Chinese, Japanese, Thai) wants `boundary: 'char'`. `createAdaptiveSmoother()` is what we use for live LLM streams: it tracks the arrival rate, trails the head by a target lag, and drains against a bounded deadline at run end (call `session.notifyRunFinalized()`, a no-op with nothing pending; `bindRunTextEvents` and `useAgUiRunSessions` do it for you). Await `session.drained()` before `finalize` so the tail finishes typing, and handle its rejection: a buffered drain the engine keeps refusing runs out its retry ladder (eight retries, about 20 s at the default `holdIdleMs`) and rejects every waiter with the engine's own error. Nothing is dropped — the text stays buffered, `pendingLength` still counts it, and an explicit `flushBuffered`, `append`, `replace` or `finalize` retries it. `session.rewrite(full)` edits text the reader has not seen yet without interrupting the reveal. Details in [docs/STREAMING.md](docs/STREAMING.md). ```ts import { StreamSession, createSmoother } from 'react-native-selectable-markdown'; @@ -106,9 +109,9 @@ import { StreamSession, createSmoother } from 'react-native-selectable-markdown' const session = new StreamSession({ smoother: createSmoother({ charsPerSecond: 300, boundary: 'word', maxLagChars: 400 }), }); -// ...append tokens... -await session.drained(); -session.finalize(); +for (const token of tokens) session.appendBuffered(token); // buffered, not append +await session.drained().catch(() => {}); // rejects if the engine gave up on the buffered tail +session.finalize(); // an explicit drain retries the held text ``` ### ag-ui @@ -125,7 +128,9 @@ function AssistantMessage(props: { events: TextMessageEvents; messageId: string } ``` -The session finalizes on message end and on run finished or failed, since an aborted stream never sends END. For a transport that owns a whole run, `bindRunTextEvents(events, store, policy?)` and its hook `useAgUiRunSessions(events, init?)` manage per-message sessions: they seed pre-existing messages without re-typing, route new ones through `appendBuffered`, and report `holding: true` until every smoother has drained at run end. Policy fields are documented on `RunBindingPolicy`. +The session finalizes on message end and on run finished or failed, since an aborted stream never sends END; a `messageId` switch settles the outgoing session as `'aborted'` rather than stranding it mid-stream, and it stays in the hook's map, so switching back shows the settled document. The third argument is either bare parse options, as above, or a full session init — `{ engine, options, coalesce, smoother, holdBackChars, holdIdleMs, repair, bufferScheduler, idleScheduler, now }`, where `smoother` is a factory because there is one session per messageId. Each delta commits on its own unless the init carries a buffering field (`smoother`, `holdBackChars`, `holdIdleMs`, either scheduler) or an explicit `coalesce: true`; `repair` and `now` deliberately do not switch coalescing on. Every field is latched when a messageId's session is created, so changing the init later reaches the next new message, not the one already streaming. What the per-message adapter does not do is hold at run end: neither `useAgUiSession` nor `bindMessageEvents` calls `notifyRunFinalized`, so run end finalizes straight through a metered tail — a smoothed reveal commits the rest in one revision instead of playing it out. + +For a transport that owns a whole run, `bindRunTextEvents(events, store, policy?)` and its hook `useAgUiRunSessions(events, init?)` (the same session fields, plus `policy`) manage per-message sessions: they seed pre-existing messages without re-typing, route new ones through `appendBuffered`, and report `holding: true` until every smoother has drained at run end. Policy fields are documented on `RunBindingPolicy`. The three run-lifecycle callbacks take an optional trailing `runId`; pass it when the transport has one, and `bindRunTextEvents` ignores a late run finished or failed from a run it already watched go spent. An id stops being spent the moment that run starts again — announce a retry that reuses it with `onRunStarted` and it keeps its right to finalize — and `onAttached` catch-up clears the spent-run memory outright, since nothing learned before a gap the binding cannot see into is trustworthy. `bindMessageEvents` observes no run start and filters nothing. ### Headless @@ -136,7 +141,7 @@ const doc = parseDocument('# Hello *world*', presets.llmChat); visit(doc, (node) => { /* node.span = { start, end } into doc.source */ }); ``` -In plain Node, import deep paths (`dist/engine/Engine`, `dist/stream/StreamSession`, ...), since the package root re-exports `react-native`. Parsing there still needs an engine; md4c is native. See [docs/NATIVE.md](docs/NATIVE.md). +In plain Node, import the `engine` and `stream` subpaths (`react-native-selectable-markdown/engine`, `react-native-selectable-markdown/stream`) or deep paths (`dist/engine/Engine`, `dist/stream/StreamSession`, ...), since the package root re-exports the React Native view layer, which imports `react-native` at load time. Those paths are declared in the `exports` map, which serves two builds: `require` takes the CommonJS tree in `dist/`, `import` takes an ES module build in `dist/esm`, and Metro's `react-native` condition still wins and still points at `src/index.ts`. The ESM build is what lets webpack, Rollup and Vite tree-shake per export rather than only drop a module nothing imports (`sideEffects: false` is declared in both package.json files, because bundlers read it from the nearest one). Types resolve under both conditions — `dist/*.d.ts` for `require`, `dist/esm/*.d.ts` for `import` — `react-native-selectable-markdown/dist`, the bare directory, resolves under both, and `npm run verify:pack` resolves *and* imports each documented deep path through both conditions out of the packed tarball. Parsing there still needs an engine; md4c is native. See [docs/NATIVE.md](docs/NATIVE.md). ## Theming @@ -145,14 +150,14 @@ import type { PartialTheme } from 'react-native-selectable-markdown'; const theme: PartialTheme = { colors: { text: '#101418', link: '#0B6E6A', codeBackground: '#F1F3F6' }, - fonts: { baseSize: 16, lineHeight: 1.5, family: 'Inter' }, + fonts: { baseSize: 16, lineHeight: 1.5, body: 'Inter' }, spacing: { blockGap: 14, listIndent: 20 }, }; ``` -Overrides merge one level deep over a base theme. The groups are `colors`, `fonts`, `spacing`, `code`, `quote`, `table`, `headings`, `rule` and `glyphs`. `colorScheme` is `'light'`, `'dark'` or `'auto'` (the default; follows the system). `mergeTheme(overrides, base)` computes a theme ahead of render. `glyphs` (bullet and task markers) are part of the projected text, so changing them shifts selection offsets; the library handles that. +Overrides merge one level deep over a base theme. The groups are `colors`, `fonts`, `spacing`, `code`, `quote`, `table`, `headings`, `rule` and `glyphs`. `colorScheme` is `'light'`, `'dark'` or `'auto'` (the default; follows the system). `mergeTheme(overrides, base)` computes a theme ahead of render. `glyphs` (bullet and task markers) are part of the projected text, so changing them shifts selection offsets; the library handles that. The font keys are `body`, `mono`, `baseSize`, `lineHeight`, `strongWeight` and the optional `strongFamily`; an unknown token is ignored, with a DEV warning. Four tokens also cross groups, each only when the token it feeds is not itself overridden: `code.borderRadius` feeds `table.borderRadius` (squaring your code blocks squares your tables), and the deprecated `spacing.quoteIndent`, `spacing.tableCellPadding` and `colors.quoteBar` feed `quote.indent`, `table.cellPaddingH`/`cellPaddingV` and `quote.barColor`. To style one mark rather than a construct (a heading ramp, a bold face instead of a weight bump), pass `attributeForMark`. Give it a stable identity; it takes part in the per-run memo. @@ -169,7 +174,9 @@ Known divergence: standalone blocks approximate list indentation with spaces ins ## Defaults for model output -No options (or `presets.commonmark`) is pure CommonMark. Use `presets.llmChat` for model output. `presets.everything` turns on every extension, spoilers included; don't feed it untrusted text. +No options (or `presets.commonmark`) turns every extension off — not a spec-conformance mode: `html` still defaults to `'strip'` and destinations are still allowlisted, both of which the table below covers. Use `presets.llmChat` for model output. `presets.everything` turns on every extension, spoilers included; don't feed it untrusted text. + +`extensions` replaces rather than extends, so an options literal naming one flag turns the rest off and a shallow spread of a preset does not help. `withOptions(preset, overrides)` layers on a preset instead, merging `extensions` and `urlPolicy` field by field; the two prefix arrays still replace deliberately. | Option | Default | `llmChat` | Notes | | --- | --- | --- | --- | @@ -178,57 +185,66 @@ No options (or `presets.commonmark`) is pure CommonMark. Use `presets.llmChat` f | `spoilers` | off | off | On only in `everything`. Parses only a balanced `\|\|x\|\|` inside one paragraph or heading; a stray `\|` stays text. | | `underline` | off | off | `_` stops meaning emphasis. On only in `everything`. | | `smartPunctuation` | off | off | Smart quotes and dashes in prose only; code and URLs stay byte-exact. On in `everything`. | -| `html` | `'strip'` | `'strip'` | `'raw'` keeps it. | -| Link schemes | `https:`, `http:`, `mailto:` | same | Anything else renders as plain text, not a dead link. Your list replaces this one; spread `DEFAULT_LINK_PREFIXES` to keep it. | -| Image schemes | `https:` | same | Blocked images render their alt text. | -| `urlPolicy.blockedLinks` | `'text'` | `'text'` | `'node'` keeps a blocked link as a `blocked: true` node for your renderer. Never navigable. | +| `html` | `'strip'` | `'strip'` | `'strip'` drops an HTML block with the lines it covers — a `
`, a `
` or a raw `` contributes nothing to the document — and drops inline tags while keeping the text between them. `
` is the exception: it becomes a hard break spanning the tag. `'raw'` keeps both as `htmlBlock` / `htmlSpan` nodes. | +| Link prefixes | `https://`, `http://`, `mailto:` | same | A case-insensitive prefix test, not scheme parsing: `https:example.com` matches nothing and renders as plain text, not a dead link. Your list replaces this one; spread `DEFAULT_LINK_PREFIXES` to keep it. A prefix reaching into a path (`myapp://checkout/`) is a scope: a destination whose path climbs back out of it with `..` (raw or percent-encoded once) is refused, one that descends and returns (`a/../b`) is not, and a `..` inside a query string or fragment is left alone. | +| Image prefixes | `https://` | same | Blocked images render their alt text. | +| `urlPolicy.blockedLinks` | `'text'` | `'text'` | `'node'` keeps a blocked link as a `blocked: true` node for your `onLinkPress`, `embed` or `classifyBlock` handler — the `link` renderer runs only for standalone blocks. Never navigable. | ### Custom link schemes The allowlist runs at parse time, so `[1](#citation-1)` or `[record](fhir://...)` collapse into plain text by default. Two ways to keep them: -- If the destination should open, add its prefix: `urlPolicy: { linkPrefixes: [...DEFAULT_LINK_PREFIXES, 'tel:'] }`. -- If it is an in-app identifier, set `blockedLinks: 'node'` and render it yourself. Nothing you don't claim can navigate. +- If the destination should open, add its prefix: `withOptions(presets.llmChat, { urlPolicy: { linkPrefixes: [...DEFAULT_LINK_PREFIXES, 'tel:'] } })`. +- If it is an in-app identifier, set `blockedLinks: 'node'` and handle it yourself. Nothing you don't claim can navigate. -```tsx -import { defaultRenderers } from 'react-native-selectable-markdown'; -import type { EngineOptions, RendererOverrides } from 'react-native-selectable-markdown'; +The `renderers` prop overrides how a node kind draws, and those overrides run for standalone blocks only. Paragraphs, headings, lists, quotes and — since they became prose kinds — code blocks, tables, rules and HTML blocks flow into a native run drawn from projected text and attributes, so a `link`, `codeBlock` or `table` override is dead there until `classifyBlock` claims the block back (DEV warns when `blockedLinks: 'node'` meets a `link` override and no other channel). What does reach a flowing run: the theme and `attributeForMark` for styling, `onLinkPress` for taps, `embed` for a real element. Links inside a run are native tappable ranges, and taps arrive at `onLinkPress({ href, blocked, start, end })`; without a handler, live links open through `Linking.openURL` and blocked ones do nothing. -// Keep the annotation, or TS widens 'node' to string. -const options: EngineOptions = { urlPolicy: { blockedLinks: 'node' } }; +Each renderer function is its own React component type, which is what makes `renderers={editing ? draft : read}` safe: swapping a renderer unmounts the old one and mounts the new one, so two renderers never share a hook list. The cost is the ordinary React one — a renderer written as an arrow literal inside JSX is a new function every render and remounts its subtree every render — so give the map and the functions in it a stable identity (module scope, or `useMemo`/`useCallback`). -const renderers: RendererOverrides = { - link: (node, ctx) => { - const m = node.blocked ? /-citation-(\d+)$/.exec(node.href) : null; - return m ? : defaultRenderers.link(node, ctx); - }, -}; +```tsx +import { openUrl, presets, withOptions } from 'react-native-selectable-markdown'; + +const options = withOptions(presets.llmChat, { urlPolicy: { blockedLinks: 'node' } }); + + { + const citation = blocked ? /^#citation-(\d+)$/.exec(href) : null; + if (citation) showCitation(Number(citation[1])); + else if (!blocked) openUrl(href); + }} +/> ``` -Inside a selection run, links are native tappable ranges and the `link` renderer does not run. Taps arrive at `onLinkPress({ href, blocked, start, end })`. Without a handler, live links open through `Linking.openURL` and blocked ones do nothing. +`openUrl` re-checks its argument against `DEFAULT_LINK_PREFIXES`, so pass your own list as its second argument (`openUrl(href, linkPrefixes)`) when `urlPolicy.linkPrefixes` is not the default — the built-in press handler does. -A renderer override changes what a node draws, not which selection run it lives in. To keep a real card *inside* the sweep, claim it through `embed`: the node projects as one placeholder character, the host reserves your declared size there and reports where it landed, and your element is overlaid on that space. Selecting across the card copies its exact markdown; `copy-text` substitutes the `text` you declare. +A renderer override changes what a node draws, not which selection run it lives in. To keep a real card *inside* the sweep, claim it through `embed`: the node projects as one placeholder character, the host reserves your declared size there and reports where it landed, and your element is overlaid on that space. Selecting across the card copies its exact markdown; `copy-text` substitutes the `text` you declare. Both this example and the `classifyBlock` one below assume the `options` above — under the default policy a `cards:` or `widget:` link has already collapsed to text before any claim or classifier sees it. ```tsx import type { EmbedRenderer } from 'react-native-selectable-markdown'; // Module scope or useCallback: a changed claim resegments and reprojects. -const embed: EmbedRenderer = (node, { topLevel }) => +const embed: EmbedRenderer = (node) => node.kind === 'link' && /^cards:/.test(node.href) ? { - width: topLevel ? 320 : 160, // declared, not measured: sizing is layout-affecting + width: 160, // declared, not measured: sizing is layout-affecting height: 88, text: '[cards]', // what copy-text shows for the card - render: (node) => , + render: () => , // closes over the narrowed node } : undefined; ``` -A block-level embed may be any height; an inline one shares a line with prose, so keep it chip-sized (on iOS a line cannot outgrow its paragraph's leading). `topLevel` is false for a node nested under a list item or blockquote, where a full-column reservation would overflow the leading margin. The card owns taps inside its bounds, so a long-press on it starts no selection. +A block-level embed may be any height; an inline one shares a line with prose, so keep it chip-sized (on iOS a line cannot outgrow its paragraph's leading). `topLevel` is true only for a block that is a direct child of the document — the one position where a full-column-width reservation is safe; the size is whatever the claim declares either way. Everything else is offered `topLevel: false`: a code block inside a list item, a table inside a blockquote, and every inline, a link in a top-level paragraph included. The card owns taps inside its bounds, so a long-press on it starts no selection. No overlay mounts while its run is the unsettled streaming tail — repair rewrites that text every tick — and the reservation is native either way, so nothing reflows when the run settles and the card appears. + +`EmbedSpec.render` carries the same identity rule as a renderer: it is the overlay's component type, so switching `render` for a span remounts the card — the point — while an arrow rebuilt on every claim remounts it on every reprojection. Give it a stable identity, reading what it needs off the `node` it is handed, when the card holds state. + +`images` decides how pictures ride along. The default `'embed'` claims a sole image in a top-level paragraph, reserves `spacing.imageWidth` × `spacing.imageHeight` (280 × 200 points) and overlays `renderers.image` on it, so an isolated picture keeps the sweep. Inline and container images keep normal layout; `'standalone'` sends the containing block to the renderer path instead, which is what you want for full-bleed or intrinsically sized pictures, or when a streamed image must draw before its run settles. -`classifyBlock` marks a block `'standalone'` so it gets its own selection scope and renderer. Use it for blocks that own a competing gesture and should end the sweep rather than flow through it. Images and spoilers are standalone already. Give it a stable identity; the document is resegmented when it changes. +`classifyBlock` marks a block `'standalone'` so it gets its own selection scope and renderer. Use it for blocks that own a competing gesture and should end the sweep rather than flow through it. A block holding a spoiler, or an image no claim covered, is standalone already. Give it a stable identity; the document is resegmented when it changes. ```tsx import type { ClassifyBlock } from 'react-native-selectable-markdown'; @@ -243,10 +259,10 @@ const classifyBlock: ClassifyBlock = (node) => markdown source │ ▼ -engine (md4c, or your own) ──► ParsedDocument: a SourceSpan on every node +stream layer (streaming only): settled prefix + repaired tail ──► parse input │ ▼ -stream layer: settled-prefix tracking + tail repair +engine (md4c, or your own) ──► ParsedDocument: a SourceSpan on every node │ ▼ selection runs: adjacent flowing blocks merged, projected to display text + decorations @@ -265,23 +281,40 @@ selection offsets ──► mapSelectionToSource ──► exact source span ─ - [docs/BENCHMARKS.md](docs/BENCHMARKS.md) and [docs/PERFORMANCE.md](docs/PERFORMANCE.md): measurements, cost model, roadmap. - [docs/FABRIC-PLAN.md](docs/FABRIC-PLAN.md): design retrospective of the new-architecture port. -## Status (0.11.x) +## Status (Unreleased) | Area | Where it stands | | --- | --- | | Parser | md4c, 651/652 on CommonMark 0.31.2 (the one failure is example 174, an unclosed HTML block inside a blockquote). The only parser; throws where not linked. | -| Streaming | Incremental tail-only parsing, checked by a prefix oracle. Coalescing, holdback, smoothers. A 147-case tail-repair corpus. | -| Selection and copy | Exact source ranges, property-tested. Code blocks, tables and rules flow through runs; images and spoilers are standalone. | -| Copy menu | Copy Text and Copy Markdown. Custom items need iOS 16+; iOS 13.4 to 15 gets the system menu only. | -| Selection host | Fabric only (`react-native >= 0.82`). CI compiles the C++ against real renderer headers and the Swift against the iOS SDK, but there is no example app yet, so on-device behaviour is reviewed rather than exercised. | -| Embeds | The `embed` prop: a claimed node flows through its run as one placeholder, the host reserves its declared size and reports the rect (`onEmbedLayout`), JS overlays the element. Removed in 0.10.0, restored in 0.11.0. Reviewed on-device like the host itself. | +| Streaming | Incremental tail-only parsing, checked by a prefix oracle. Coalescing, holdback, smoothers. A 251-case tail-repair corpus. | +| Selection and copy | Exact source ranges, property-tested. Code blocks, tables and rules flow through runs; a block holding a spoiler, or an image no claim covered, is standalone — the whole block, not just the construct. Selections never span hosts: a document's runs clear another's unless `exclusiveSelection={false}` opts them out in both directions — which keeps every range reported and exact for copy, but only the run holding focus draws a highlight. | +| Copy menu | Copy Text and Copy Markdown, retitleable from JS and extensible with your own ids. With no title from JS the labels come from platform resources (`NSLocalizedString`, `res/values/strings.xml`), which the host app can override. Custom items need iOS 16+; iOS 13.4 to 15 gets the system menu only. | +| Imperative selection | `onSelectionChange`, and a ref with `getSelection()`, `clearSelection()` and `setSelection(span)`. Codegen commands on both hosts; reviewed rather than exercised on device, like the host itself. No scroll-to-span. | +| Selection host | Fabric only (`react-native >= 0.82`). CI compiles C++ and runs codegen against React Native 0.82.1, the supported minimum release line, and checks Swift against the iOS SDK. There is no example app yet, so on-device behaviour is reviewed rather than exercised. | +| Accessibility | Links, headings, list items and table cells survive run merging: each is a VoiceOver/TalkBack focus stop, a link activates through the same press path a tap takes, a heading carries the platform heading trait, and on Android an item or cell carries its position (`CollectionItemInfoCompat`), which iOS has no trait for. Code-block and blockquote structure is still flattened by merging — neither platform has a primitive for it. `accessible` or `accessibilityRole` on the container collapses the document to one element, so label it but do not make it a leaf. Standalone blocks keep the roles `renderers.tsx` sets. Reviewed, not exercised: no screen reader has run against it here. | +| Embeds | The `embed` prop: a claimed node flows through its run as one placeholder, the host reserves its declared size and reports the rect (`onEmbedLayout`), JS overlays the element. Images are claimed this way by default. Removed in 0.10.0, restored in 0.11.0 ([CHANGELOG.md](CHANGELOG.md) is the record; the 0.10.0 release notes are one squashed commit). Reviewed on-device like the host itself. | +| Package surface | The entry names every export instead of re-exporting modules wholesale, so internals (the flat-buffer decoder, the host-binding lookup, the agui session map) moved to deep paths under `dist/`, which the `exports` map declares and `verify:pack` resolves; the `classifyBlock` function is `classifyTopLevelBlock`, with the old name kept as a deprecated alias. `withOptions(preset, overrides)` composes options without flattening a preset. | | Android selection preservation | Not implemented, and the largest known gap. Each streamed text swap drops the selection. iOS preserves it. | | Benchmarks | Node harnesses in `bench/`. On-device numbers are planned. | | Example app | Planned. | ## Benchmarks -Measured 2026-09-01 on an Apple M2 Max with an arm64 Node 22, from the harnesses in `bench/`. Markdown-to-HTML over a 289 kB spec-derived corpus, one fresh process per library, all in the same run: this package 15.8 MB/s, commonmark.js 10.0, marked 9.4, markdown-it 6.2. The number for this package includes building the span-carrying AST and serializing it to HTML. Streaming appends parse at most 277 characters regardless of stream length and cost about 2.7 µs each end to end. These are V8 numbers; on Hermes the JS decode (43 to 53% of a parse) will be slower. Methodology and full tables in [docs/BENCHMARKS.md](docs/BENCHMARKS.md). +Measured on an Apple M2 Max with an arm64 Node 22, from the harnesses in `bench/`: the cross-parser comparison is the 2026-09-01 run, everything else a 2026-09-02 re-run. Markdown-to-HTML over a 289 kB spec-derived corpus, one fresh process per library, all in the same run: this package 15.8 MB/s, commonmark.js 10.0, marked 9.4, markdown-it 6.2. The number for this package includes building the span-carrying AST and serializing it to HTML. `engine.parse` on a 64 B tail cost 3.0 µs in the re-run — a parse alone, without the tail repair, span splice and snapshot an append also pays. What an append actually parses depends on whether the stream anchors. On the bundled sprint-review transcript it does: a blank line closes a paragraph and everything above it freezes, so appends parse a mean of 105 and at most 277 of the 1,162 final characters. A stream that never anchors gets none of that. `StreamSession.isAnchorSafe` is false for a list and for unclosed or indented code, and a blank line does not end a list, so `conformance/fixtures/transcript-giant-list.json` — 21.9 kB in 2,484 deltas, the commonest long LLM answer shape — parses a mean of 10,854 and a max of 21,881 of 21,927 characters, every append reaching the engine, and its incremental-vs-full ratio comes out above 1: tail-only parsing costs more there than reparsing the whole document per token. `npm run bench:streaming` replays both. These are V8 numbers; on Hermes the JS decode (38 to 51% of a parse) will be slower. Methodology and full tables in [docs/BENCHMARKS.md](docs/BENCHMARKS.md). + +### Jest in React Native applications + +The React Native Jest preset resolves this package to TypeScript source. Include it in your existing transform allowlist so Babel transforms files in `node_modules`: + +```js +transformIgnorePatterns: [ + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?|react-native-selectable-markdown)/)', +], +``` + +Add the package name to your existing expression, preserving other allowed packages; do not append a second ignore pattern. Headless `engine` and `stream` imports also use source under the React Native condition; plain Node resolves their compiled entries. Native parser tests additionally need the Node addon described in `native/node/README.md`. + +Create `withOptions(...)` results outside render or memoize them. It deliberately returns a fresh, independently mutable object, and passing a new options object reparses a static document. ## Contributing @@ -294,7 +327,18 @@ npm run verify:pack # the packed tarball loads npm run check:codegen && npm run check:fabric-cpp && npm run check:swift ``` -Without a compiler the native suites report as skipped, not passed. CI builds the addon as a hard gate. See [native/node/README.md](native/node/README.md). +Without a compiler the native suites report as skipped, not passed. CI, the release workflow and `npm run release` all build the addon as a hard gate before the suite runs; `npm run release -- --skip-tests` opts out of the build and the suite together, and says so. See [native/node/README.md](native/node/README.md). + +## Releasing + +Publishing is tag-triggered. The tarball `npm run release` writes locally is a dry run, never the published artifact. + +1. Move the Unreleased entries into the target version's section in [CHANGELOG.md](CHANGELOG.md), `## [X.Y.Z] — `. +2. Run `npm run release `. It checks types and tests, bumps the manifest and lockfile, verifies the proposed tag against the changelog, verifies the package, and packs a local dry run. Unreleased BREAKING entries fail both this guard and CI; a failure rolls the version bump back. Nothing is committed, tagged or published. +3. `git add package.json package-lock.json CHANGELOG.md && git commit -m "release X.Y.Z"`. +4. `git push origin main && git tag vX.Y.Z && git push origin vX.Y.Z`. + +The tag runs `release.yml`: the macOS gates (Swift, the iOS header set), then everything CI runs, then its own `npm pack`, `npm publish --provenance --access public`, and the GitHub release with that same tarball attached. A hand `npm publish ` from a laptop is not the supported path — it ships without provenance, which only that workflow can mint, and without the release gates. `scripts/release.mjs` prints steps 2 to 4 with the version filled in. ## Prior art diff --git a/SelectableMarkdown.podspec b/SelectableMarkdown.podspec index e772e70..d622268 100644 --- a/SelectableMarkdown.podspec +++ b/SelectableMarkdown.podspec @@ -28,14 +28,7 @@ Pod::Spec.new do |s| # not apply to them, and they need no C++ flags). # # THE FIRST GLOB IS `platform/ios/*` AND NOT `platform/ios/**/*`, WHICH IS - # THE POINT. platform/ios/fabric/ is added below, only under the new - # architecture. Its files are individually wrapped in - # `#ifdef RCT_NEW_ARCH_ENABLED` as well, so a recursive glob here would still - # produce empty translation units rather than errors — but "empty translation - # unit" is one preprocessor slip away from "an old-architecture app fails to - # build on a React-RCTFabric header it never asked for", and this package has - # no way to reproduce that failure. Belt and braces, as the guard comments in - # those files say. + # THE POINT: a recursive glob would make any new subdirectory's headers public. source_files = [ "platform/ios/*.{swift,h,m,mm}", "platform/cpp/*.{h,cpp}", @@ -97,9 +90,8 @@ Pod::Spec.new do |s| # anyway (new_architecture.rb:96 takes it from # Helpers::Constants.cxx_language_standard), and React Native's own headers # require it — react/utils/hash_combine.h:16 declares a `concept`. Naming - # it here keeps the standard explicit for `npm run check:fabric-cpp - # --syntax-only platform/cpp/*.cpp`, which is what proves the markdown - # engine still compiles clean at it. + # it here keeps the standard explicit for `check:fabric-cpp -- --syntax-only` + # (the bare `--` matters: npm swallows flags before it). "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", "DEFINES_MODULE" => "YES", # Every entry is a bare-name include somewhere in the tree, so it has to @@ -133,9 +125,7 @@ Pod::Spec.new do |s| # SelectableMarkdownModule.mm reaches the runtime through selectors that # both RCTCxxBridge and the bridgeless RCTBridgeProxy implement, so it # needs no header out of React-NativeModulesApple or React-runtimeexecutor - # — which also means the old-architecture install of this pod cannot drag - # new-arch pods into an old-architecture app, and cannot fail to build when - # they are absent. + # — so it builds the same with or without a bridge. "HEADER_SEARCH_PATHS" => [ "$(inherited)", '"$(PODS_TARGET_SRCROOT)/platform/cpp"', diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index c1e2d0e..da1b771 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -108,7 +108,7 @@ find_package(ReactAndroid CONFIG QUIET) if(NOT ReactAndroid_FOUND) message(FATAL_ERROR "react-native-selectable-markdown: the ReactAndroid prefab was not found. " - "This library needs React Native >= 0.73 with android.buildFeatures.prefab " + "This library needs React Native >= 0.82 with android.buildFeatures.prefab " "enabled in the consuming module.") endif() @@ -118,14 +118,8 @@ endif() # else, so libjsi.so satisfies every undefined symbol we produce. # # The reason this matters is version reach. `jsi` is the one prefab module -# React Native has published unchanged across every version this package -# supports — verified in ReactAndroid/build.gradle(.kts) on 0.73, 0.75, 0.76, -# 0.79 and 0.81. Everything else moved: 0.73-0.75 published a target per -# feature (react_nativemodule_core, reactnativejni, turbomodulejsijni, ...) -# and 0.76 collapsed the lot into a single `reactnative`. Linking any of them -# "in case we need it later" would buy nothing today and would make this file -# need a version ladder — so it does not, and if a CallInvoker is ever -# genuinely needed the ladder can be added then, with a symbol to justify it. +# React Native has published under an unchanged name since 0.73; every other +# target was renamed in 0.76, so linking only jsi needs no version ladder. # # FABRIC DOES NOT CHANGE THAT, AND THIS IS THE PART PEOPLE EXPECT TO BREAK. # The Fabric shadow node, its component descriptor and the JNI measurer are @@ -149,7 +143,7 @@ endif() if(NOT TARGET ReactAndroid::jsi) message(FATAL_ERROR "react-native-selectable-markdown: ReactAndroid::jsi is missing from the " - "React Native prefab. Expected React Native >= 0.73.") + "React Native prefab. Expected React Native >= 0.82.") endif() target_link_libraries(selectable-markdown PRIVATE ReactAndroid::jsi) diff --git a/android/build.gradle b/android/build.gradle index ebd89b5..d01d4b3 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -57,14 +57,8 @@ apply plugin: 'org.jetbrains.kotlin.android' // // WHAT APPLYING IT BUYS. ReactPlugin.kt:91-93 runs configureCodegen(isLibrary // = true) for every com.android.library that applies it, and the onlyIf guards -// on both codegen tasks (:177, :201) pass for libraries REGARDLESS OF -// ARCHITECTURE. So the generated SelectableRunHostManagerInterface is on the -// classpath in old-architecture apps too, which is what lets -// SelectableRunHostViewManager implement it unconditionally — the single -// compile-time link between src/view/SelectableRunHostNativeComponent.ts and -// the Kotlin setters. Rename a prop in the spec without renaming the setter -// and this module stops compiling, instead of shipping a prop that silently -// never arrives. +// on both codegen tasks (:177, :201) pass for every library, so the generated +// SelectableRunHostManagerInterface makes a spec prop rename a Kotlin compile error. apply plugin: 'com.facebook.react' def safeExtGet(prop, fallback) { @@ -111,11 +105,11 @@ def reactNativeArchitectures() { android { namespace 'com.selectablemarkdown' - compileSdk safeExtGet('compileSdkVersion', 34) + compileSdk safeExtGet('compileSdkVersion', 36) defaultConfig { - minSdkVersion safeExtGet('minSdkVersion', 23) - targetSdkVersion safeExtGet('targetSdkVersion', 34) + minSdkVersion safeExtGet('minSdkVersion', 24) + targetSdkVersion safeExtGet('targetSdkVersion', 36) externalNativeBuild { cmake { @@ -197,4 +191,6 @@ repositories { dependencies { // Version is resolved by the host app's React Native gradle plugin. implementation 'com.facebook.react:react-android' + // Also transitive through appcompat; named because RunAccessibility.kt imports it directly. + implementation 'androidx.customview:customview:1.1.0' } diff --git a/android/src/main/java/com/selectablemarkdown/RunAccessibility.kt b/android/src/main/java/com/selectablemarkdown/RunAccessibility.kt new file mode 100644 index 0000000..71461c1 --- /dev/null +++ b/android/src/main/java/com/selectablemarkdown/RunAccessibility.kt @@ -0,0 +1,322 @@ +package com.selectablemarkdown + +import android.graphics.Rect +import android.os.Bundle +import android.view.View +import android.view.KeyEvent +import android.widget.TextView +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeProviderCompat +import androidx.customview.widget.ExploreByTouchHelper +import kotlin.math.ceil +import kotlin.math.floor + +/** A link when `pressable` is set, else a block role; row and column are one-based as on the wire. */ +internal data class RunAccessibilityNode( + val start: Int, + val end: Int, + val pressable: SelectableRunHostView.Pressable?, + val role: String? = null, + val row: Int? = null, + val rowCount: Int? = null, + val column: Int? = null, + val columnCount: Int? = null, +) + +/** + * Only roles with a platform primitive exist, so the library ships no announcement strings. + * A covered range is read twice, as in React Native's `` links: rewriting the TextView's + * own announcement would cost its text navigation. + */ +internal object RunAccessibility { + + /** Mirrors `RunSemanticRole` in src/view/runAttributes.ts. */ + const val ROLE_HEADING = "heading" + const val ROLE_LIST_ITEM = "listItem" + const val ROLE_TABLE_CELL = "tableCell" + + fun resolve( + text: String, + attributes: RunAttributedText.Spec, + pressables: List, + ): List { + val length = text.length + if (length == 0) return emptyList() + + val nodes = ArrayList(pressables.size + 1) + for (pressable in pressables) { + val start = pressable.start.coerceIn(0, length) + val end = pressable.end.coerceIn(start, length) + if (end <= start) continue + nodes.add(RunAccessibilityNode(start, end, pressable)) + } + + for (attribute in attributes.attributes) { + val role = attribute.role + if (role != ROLE_HEADING && role != ROLE_LIST_ITEM && role != ROLE_TABLE_CELL) { + continue + } + // Under prop skew these offsets can run past the text in hand. + val start = attribute.start.coerceIn(0, length) + val end = attribute.end.coerceIn(start, length) + if (end <= start) continue + nodes.add( + RunAccessibilityNode( + start = start, + end = end, + pressable = null, + role = role, + row = attribute.roleRow, + rowCount = attribute.roleRowCount, + column = if (role == ROLE_TABLE_CELL) attribute.roleColumn else 1, + columnCount = + if (role == ROLE_TABLE_CELL) attribute.roleColumnCount else 1, + ) + ) + } + + if (nodes.size > 1) { + // A block role reads before a link starting at the same offset, as the document does. + nodes.sortWith( + compareBy { it.start } + .thenBy { if (it.pressable == null) 0 else 1 } + ) + } + return nodes + } + + /** The largest real grid in `nodes`: the host TextView is the virtual views' only ancestor, so it can declare just one. */ + fun collectionOf(nodes: List): Pair? { + // Insertion order plus a stable sort breaks size ties by document order. + val sizes = LinkedHashMap, Int>() + for (node in nodes) { + val shape = shapeOf(node) ?: continue + sizes[shape] = (sizes[shape] ?: 0) + 1 + } + if (sizes.isEmpty()) return null + for (candidate in sizes.entries.sortedByDescending { it.value }) { + if (describesOneGrid(nodes, candidate.key)) return candidate.key + } + return null + } + + /** The shape is a node's whole collection identity: nothing on the wire names the list an item belongs to. */ + private fun shapeOf(node: RunAccessibilityNode): Pair? { + if (node.row == null) return null + val rows = node.rowCount ?: return null + return Pair(rows, node.columnCount ?: 1) + } + + private fun describesOneGrid( + nodes: List, + shape: Pair, + ): Boolean { + val seen = HashSet>() + for (node in nodes) { + if (shapeOf(node) != shape) continue + val row = node.row ?: continue + val column = node.column ?: 1 + if (row > shape.first || column > shape.second) return false + if (!seen.add(Pair(row, column))) return false + } + return seen.isNotEmpty() + } + + /** A node of another grid would be phrased against the declared grid's total. */ + fun carriesItemInfo(node: RunAccessibilityNode, collection: Pair?): Boolean { + if (collection == null) return true + return shapeOf(node) == collection + } +} + +/** TalkBack activates links with ACTION_CLICK, never the touches the host observes, so links need virtual views. */ +internal class RunAccessibilityHelper( + private val textView: TextView, + private val onLinkActivated: (SelectableRunHostView.Pressable) -> Unit, +) : ExploreByTouchHelper(textView) { + + private var nodes: List = emptyList() + + private var collection: Pair? = null + + fun setNodes(value: List) { + // Fabric re-delivers every prop per commit; invalidating on each would flood events at streaming rate. + if (value == nodes) return + nodes = value + collection = RunAccessibility.collectionOf(value) + invalidateRoot() + } + + /** No provider for a run without nodes, so it stays exactly a stock TextView. */ + override fun getAccessibilityNodeProvider(host: View): AccessibilityNodeProviderCompat? { + if (nodes.isEmpty()) return null + return super.getAccessibilityNodeProvider(host) + } + + @Suppress("DEPRECATION") + override fun onPopulateNodeForHost(node: AccessibilityNodeInfoCompat) { + super.onPopulateNodeForHost(node) + val declared = collection ?: return + node.setCollectionInfo( + AccessibilityNodeInfoCompat.CollectionInfoCompat.obtain( + declared.first, + declared.second, + // Not hierarchical: only one flat grid is ever declared. + false, + ) + ) + } + + override fun getVirtualViewAt(x: Float, y: Float): Int { + if (nodes.isEmpty()) return ExploreByTouchHelper.INVALID_ID + val offset = offsetAt(x, y) ?: return ExploreByTouchHelper.INVALID_ID + var block = ExploreByTouchHelper.INVALID_ID + var blockLength = Int.MAX_VALUE + for (index in nodes.indices) { + val node = nodes[index] + if (offset < node.start || offset >= node.end) continue + if (node.pressable != null) return index + val length = node.end - node.start + if (length < blockLength) { + block = index + blockLength = length + } + } + return block + } + + private var keyboardNavigation = false + + private inline fun linksOnly(action: () -> T): T { + keyboardNavigation = true + return try { action() } finally { keyboardNavigation = false } + } + + fun dispatchLinkKeyEvent(event: KeyEvent): Boolean = linksOnly { dispatchKeyEvent(event) } + + fun onHostFocusChanged(focused: Boolean, direction: Int, previous: Rect?) { + linksOnly { onFocusChanged(focused, direction, previous) } + } + + override fun getVisibleVirtualViews(virtualViewIds: MutableList) { + for (index in nodes.indices) { + if (keyboardNavigation && nodes[index].pressable == null) continue + virtualViewIds.add(index) + } + } + + /** ExploreByTouchHelper reads only the deprecated `setBoundsInParent`, and throws when a child leaves it unset. */ + @Suppress("DEPRECATION") + override fun onPopulateNodeForVirtualView( + virtualViewId: Int, + node: AccessibilityNodeInfoCompat, + ) { + val range = nodes.getOrNull(virtualViewId) + val bounds = if (range == null) null else boundsFor(range) + if (range == null || bounds == null) { + // Ellipsized or not laid out yet: an empty description makes the reader skip it. + node.contentDescription = "" + node.setBoundsInParent(Rect(0, 0, 1, 1)) + return + } + node.contentDescription = textOf(range) + bounds.offset(textView.scrollX, textView.scrollY) + node.setBoundsInParent(bounds) + if (range.pressable != null) { + node.className = "android.widget.TextView" + node.roleDescription = textView.context.getString(com.facebook.react.R.string.link_description) + node.isClickable = true + node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK) + return + } + if (range.role == RunAccessibility.ROLE_HEADING) { + node.isHeading = true + return + } + val row = range.row ?: return + if (!RunAccessibility.carriesItemInfo(range, collection)) return + // CollectionItemInfo is zero-based; the wire is one-based because 0 is its absent sentinel. + node.setCollectionItemInfo( + AccessibilityNodeInfoCompat.CollectionItemInfoCompat.obtain( + row - 1, + 1, + (range.column ?: 1) - 1, + 1, + // A GFM table's row 1 is always its header row. + range.role == RunAccessibility.ROLE_TABLE_CELL && row == 1, + ) + ) + } + + override fun onPerformActionForVirtualView( + virtualViewId: Int, + action: Int, + arguments: Bundle?, + ): Boolean { + if (action != AccessibilityNodeInfoCompat.ACTION_CLICK) return false + val pressable = nodes.getOrNull(virtualViewId)?.pressable ?: return false + onLinkActivated(pressable) + return true + } + + private fun textOf(range: RunAccessibilityNode): CharSequence { + val text = textView.text ?: return "" + val start = range.start.coerceIn(0, text.length) + val end = range.end.coerceIn(start, text.length) + if (end <= start) return "" + return text.subSequence(start, end).toString() + } + + /** A wrapping range gets its whole first line: a reader activates at the centre, which a two-line box can miss. */ + private fun boundsFor(range: RunAccessibilityNode): Rect? { + val layout = textView.layout ?: return null + val text = textView.text ?: return null + val length = text.length + val start = range.start.coerceIn(0, length) + val end = range.end.coerceIn(start, length) + if (end <= start) return null + + val firstLine = layout.getLineForOffset(start) + val lastLine = layout.getLineForOffset(end - 1) + val left: Float + val right: Float + if (firstLine == lastLine) { + // An offset on a line break resolves to the next line's leading edge. + val endOnLine = minOf(end, layout.getLineVisibleEnd(firstLine)) + val startX = layout.getPrimaryHorizontal(start) + val endX = + if (endOnLine > start) layout.getPrimaryHorizontal(endOnLine) else startX + left = minOf(startX, endX) + right = maxOf(startX, endX) + } else { + left = layout.getLineLeft(firstLine) + right = layout.getLineRight(firstLine) + } + + val dx = textView.totalPaddingLeft - textView.scrollX + val dy = textView.totalPaddingTop - textView.scrollY + val top = layout.getLineTop(firstLine) + dy + val bottom = layout.getLineBottom(firstLine) + dy + if (bottom <= top) return null + val boxLeft = floor(left).toInt() + dx + val boxRight = ceil(right).toInt() + dx + // ExploreByTouchHelper rejects empty bounds. + return Rect(boxLeft, top, maxOf(boxRight, boxLeft + 1), bottom) + } + + /** Bounded to the line's band and extent: `getOffsetForHorizontal` alone snaps a margin hover to the nearest character. */ + private fun offsetAt(x: Float, y: Float): Int? { + val layout = textView.layout ?: return null + val localX = x - textView.totalPaddingLeft + textView.scrollX + val localY = y - textView.totalPaddingTop + textView.scrollY + if (localY < 0f || localY > layout.height.toFloat()) return null + val line = layout.getLineForVertical(localY.toInt()) + if (localY < layout.getLineTop(line).toFloat() || + localY >= layout.getLineBottom(line).toFloat() + ) { + return null + } + if (localX < layout.getLineLeft(line) || localX > layout.getLineRight(line)) return null + return layout.getOffsetForHorizontal(line, localX) + } +} diff --git a/android/src/main/java/com/selectablemarkdown/RunAttributedText.kt b/android/src/main/java/com/selectablemarkdown/RunAttributedText.kt index 96bbeb5..e6eee05 100644 --- a/android/src/main/java/com/selectablemarkdown/RunAttributedText.kt +++ b/android/src/main/java/com/selectablemarkdown/RunAttributedText.kt @@ -5,6 +5,7 @@ import android.graphics.Typeface import android.os.Build import android.text.Spannable import android.text.SpannableString +import android.text.Spanned import android.text.TextPaint import android.text.style.AbsoluteSizeSpan import android.text.style.BackgroundColorSpan @@ -13,7 +14,6 @@ import android.text.style.LineHeightSpan import android.text.style.MetricAffectingSpan import android.text.style.StrikethroughSpan import android.text.style.StyleSpan -import android.text.style.TypefaceSpan import android.text.style.UnderlineSpan import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap @@ -58,6 +58,17 @@ object RunAttributedText { val strikethrough: Boolean, val color: Int?, val backgroundColor: Int?, + /** Screen-reader role from `RunSemanticRole` in src/view/runAttributes.ts; never becomes a span. */ + val role: String?, + /** Heading level or list depth; unused, since no Android accessibility primitive carries a rank. */ + val roleLevel: Int?, + /** One-based position in its collection; a table's row 1 is its header row. */ + val roleRow: Int?, + /** Item or row count, header row included. */ + val roleRowCount: Int?, + /** Set only for `tableCell`. */ + val roleColumn: Int?, + val roleColumnCount: Int?, ) /** @@ -70,6 +81,13 @@ object RunAttributedText { * props are parsed once, on arrival, into this. */ class Spec internal constructor(internal val attributes: List) { + internal val layoutAttributes: List by lazy(LazyThreadSafetyMode.PUBLICATION) { + attributes.filter { it.fontFamily != null || it.fontSizeSp != null || + it.lineHeightSp != null || it.fontWeight != null || it.italic || + it.underline || it.strikethrough || it.color != null || it.backgroundColor != null + }.map { it.copy(role = null, roleLevel = null, roleRow = null, + roleRowCount = null, roleColumn = null, roleColumnCount = null) } + } companion object { val EMPTY = Spec(emptyList()) } @@ -123,6 +141,13 @@ object RunAttributedText { // any colour format React Native accepts. color = optInt(entry, "color"), backgroundColor = optInt(entry, "backgroundColor"), + role = optString(entry, "role"), + // 0 is the wire's absent sentinel. + roleLevel = optInt(entry, "roleLevel")?.takeIf { it > 0 }, + roleRow = optInt(entry, "roleRow")?.takeIf { it > 0 }, + roleRowCount = optInt(entry, "roleRowCount")?.takeIf { it > 0 }, + roleColumn = optInt(entry, "roleColumn")?.takeIf { it > 0 }, + roleColumnCount = optInt(entry, "roleColumnCount")?.takeIf { it > 0 }, ) } @@ -189,6 +214,9 @@ object RunAttributedText { ): Spannable { val out = SpannableString(text) if (text.isEmpty()) return out + // JS emits properly nested ranges outermost-first, so popping by start leaves what still covers + // an entry; only font-bearing entries join, since table-cell and embed entries arrive out of order. + val fontStack = ArrayList() for (attribute in spec.attributes) { // Clamp: offsets were computed against the text JS sent, which // under prop skew can differ in length from the text in hand. @@ -197,7 +225,31 @@ object RunAttributedText { if (end <= start) continue val flags = Spannable.SPAN_EXCLUSIVE_EXCLUSIVE - attribute.fontFamily?.let { out.setSpan(TypefaceSpan(it), start, end, flags) } + var family = attribute.fontFamily + var weight = attribute.fontWeight + var italic = attribute.italic + var faceChanged = false + if (family != null || weight != null || italic) { + while (fontStack.isNotEmpty() && fontStack[fontStack.size - 1].end <= start) { + fontStack.removeAt(fontStack.size - 1) + } + val covering = fontStack.lastOrNull() + if (covering == null) { + faceChanged = true + } else { + if (family == null) family = covering.family + if (weight == null) weight = covering.weight + // Slant only turns on, as with `StyleSpan`, which ORs its style in. + italic = italic || covering.italic + faceChanged = family != covering.family || + weight != covering.weight || + italic != covering.italic + } + fontStack.add(FontFrame(end, family, weight, italic)) + } + if (faceChanged) { + family?.let { out.setSpan(RunTypefaceSpan(it, weight, italic), start, end, flags) } + } attribute.fontSizeSp?.let { out.setSpan( AbsoluteSizeSpan(PixelUtil.toPixelFromSP(it).toInt()), @@ -214,10 +266,11 @@ object RunAttributedText { flags, ) } - attribute.fontWeight?.let { weight -> + // The entry's own weight, so an inherited one is not applied a second time. + attribute.fontWeight?.let { declared -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - out.setSpan(RunFontWeightSpan(weight), start, end, flags) - } else if (weight >= 600) { + out.setSpan(RunFontWeightSpan(declared), start, end, flags) + } else if (declared >= 700) { out.setSpan(StyleSpan(Typeface.BOLD), start, end, flags) } } @@ -230,6 +283,9 @@ object RunAttributedText { } } + // After the attribute line heights it may raise, before the decoration row padding that adjusts them. + RunEmbeds.applyLineHeights(out, embeds) + // The layout-affecting half of the decoration channel (leading // margins, tab-stop columns), after the attribute spans on purpose: // column widths are measured off `out`, so a cell must already carry @@ -242,17 +298,43 @@ object RunAttributedText { RunDecorations.applyLayoutSpans(out, decorations, paint) } - // Embed reservations last, though the position is symmetry rather - // than necessity: a ReplacementSpan supplies its metrics through - // `getSize` during measurement, so its insertion order relative to - // the LineHeightSpans above is immaterial — `chooseHeight` always - // runs after the glyph metrics are in. What DOES depend on order is - // among the LineHeightSpans themselves: the embed-height `lineHeight` - // attribute JS emits after the base one is what finally sizes the - // placeholder's line (see RunEmbedSpan for the whole story). RunEmbeds.applySpans(out, embeds) return out } + + private class FontFrame( + val end: Int, + val family: String?, + val weight: Int?, + val italic: Boolean, + ) +} + +/** Resolves at paint time: the spannable is cached, and a `Typeface` baked before `RunTypefaces.install` would freeze the system-map fallback. */ +internal class RunTypefaceSpan( + private val family: String, + private val weight: Int?, + private val italic: Boolean, +) : MetricAffectingSpan() { + + override fun updateMeasureState(paint: TextPaint) = update(paint) + + override fun updateDrawState(paint: TextPaint) = update(paint) + + private fun update(paint: TextPaint) { + val current = paint.typeface ?: Typeface.DEFAULT + paint.typeface = + RunTypefaces.resolve(family, weight ?: weightOf(current), italic || current.isItalic) + } + + private fun weightOf(current: Typeface): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + current.weight + } else if (current.isBold) { + RunTypefaces.WEIGHT_BOLD + } else { + RunTypefaces.WEIGHT_NORMAL + } } /** @@ -331,7 +413,7 @@ internal class RunLineHeightSpan(heightPx: Float) : LineHeightSpan { // Rounded up, once, at construction. StaticLayout works in whole pixels, // and rounding per line would let a run's height drift from the sum of its // line heights. - private val lineHeight: Int = ceil(heightPx.toDouble()).toInt() + internal val lineHeightPx: Int = ceil(heightPx.toDouble()).toInt() override fun chooseHeight( text: CharSequence?, @@ -341,32 +423,33 @@ internal class RunLineHeightSpan(heightPx: Float) : LineHeightSpan { v: Int, fm: Paint.FontMetricsInt ) { - if (fm.descent > lineHeight) { + if (text is Spanned && (text.getSpanStart(this) >= end || text.getSpanEnd(this) <= start)) return + if (fm.descent > lineHeightPx) { // Not even the descent fits. Keep as much of it as there is room // for and give up everything above the baseline. - fm.descent = min(lineHeight.toDouble(), fm.descent.toDouble()).toInt() + fm.descent = min(lineHeightPx.toDouble(), fm.descent.toDouble()).toInt() fm.bottom = fm.descent fm.ascent = 0 fm.top = fm.ascent - } else if (-fm.ascent + fm.descent > lineHeight) { + } else if (-fm.ascent + fm.descent > lineHeightPx) { // The descent fits; keep all of it and as much ascent as is left. fm.bottom = fm.descent - fm.ascent = -lineHeight + fm.descent + fm.ascent = -lineHeightPx + fm.descent fm.top = fm.ascent - } else if (-fm.ascent + fm.bottom > lineHeight) { + } else if (-fm.ascent + fm.bottom > lineHeightPx) { // Glyphs fit; the font's extra bottom leading does not, so trim it. fm.top = fm.ascent - fm.bottom = fm.ascent + lineHeight - } else if (-fm.top + fm.bottom > lineHeight) { + fm.bottom = fm.ascent + lineHeightPx + } else if (-fm.top + fm.bottom > lineHeightPx) { // Only the font's extra top leading is left to trim. - fm.top = fm.bottom - lineHeight + fm.top = fm.bottom - lineHeightPx } else { // There is room to spare: split it evenly above and below. Rounding // up on the negative side and down on the positive one makes // bottom - top come out to exactly the requested height even when // the surplus is odd, which is what keeps a run's measured height - // equal to lineCount * lineHeight. - val additional = lineHeight - (-fm.top + fm.bottom) + // equal to lineCount * lineHeightPx. + val additional = lineHeightPx - (-fm.top + fm.bottom) val top = (fm.top - ceil(additional / 2.0f)).toInt() val bottom = (fm.bottom + floor(additional / 2.0f)).toInt() fm.top = top diff --git a/android/src/main/java/com/selectablemarkdown/RunDecorations.kt b/android/src/main/java/com/selectablemarkdown/RunDecorations.kt index a5f6cb5..eaa5b1c 100644 --- a/android/src/main/java/com/selectablemarkdown/RunDecorations.kt +++ b/android/src/main/java/com/selectablemarkdown/RunDecorations.kt @@ -114,6 +114,48 @@ object RunDecorations { ) } + /** + * Room in dp for a box at a run's edge, which has no '\n\n' blank line to paint its padding into. + * Largest wins, not the sum: boxes sharing an edge are drawn from it. + */ + internal fun edgePaddingDp(spec: Spec, textLength: Int): EdgePadding { + if (spec.decorations.isEmpty() || textLength <= 0) return EdgePadding.NONE + var top = 0f + var bottom = 0f + for (decoration in spec.decorations) { + if (decoration.kind != "box") continue + val start = decoration.start.coerceIn(0, textLength) + val end = decoration.end.coerceIn(start, textLength) + if (end <= start) continue + if (start == 0) top = maxOf(top, decoration.paddingTop) + if (end == textLength) bottom = maxOf(bottom, decoration.paddingBottom) + } + if (top <= 0f && bottom <= 0f) return EdgePadding.NONE + return EdgePadding(top, bottom) + } + + internal data class EdgePadding(val top: Float, val bottom: Float) { + companion object { + val NONE = EdgePadding(0f, 0f) + } + } + + /** Rounded up once here so the measured height and the view's Int padding agree; short by a fraction, a border lands on the glyphs. */ + internal fun edgePaddingPx(spec: Spec, textLength: Int): EdgePaddingPx { + val dp = edgePaddingDp(spec, textLength) + if (dp.top <= 0f && dp.bottom <= 0f) return EdgePaddingPx.NONE + return EdgePaddingPx( + ceil(PixelUtil.toPixelFromDIP(dp.top)).toInt(), + ceil(PixelUtil.toPixelFromDIP(dp.bottom)).toInt(), + ) + } + + internal data class EdgePaddingPx(val top: Int, val bottom: Int) { + companion object { + val NONE = EdgePaddingPx(0, 0) + } + } + /** * The layout-affecting half, applied to the spannable the one builder * produced — after the attribute spans, deliberately: tab-stop columns diff --git a/android/src/main/java/com/selectablemarkdown/RunEmbeds.kt b/android/src/main/java/com/selectablemarkdown/RunEmbeds.kt index edfa5c0..8e05a35 100644 --- a/android/src/main/java/com/selectablemarkdown/RunEmbeds.kt +++ b/android/src/main/java/com/selectablemarkdown/RunEmbeds.kt @@ -8,6 +8,7 @@ import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType import com.facebook.react.uimanager.PixelUtil +import kotlin.math.max /** * The `embeds` prop: reserved rectangles for one run — each a @@ -64,18 +65,59 @@ object RunEmbeds { val embedId = optInt(entry, "embedId") ?: return null val width = optFloat(entry, "width") val height = optFloat(entry, "height") - // One placeholder character, a non-negative id, and a positive size: - // anything else — including the 0.0 unset sentinel the codegen - // struct documents — reserves nothing. + // Anything but one placeholder, a non-negative id and a positive finite size reserves nothing; + // `<= 0f` alone lets NaN and Infinity through, and Infinity saturates to Int.MAX_VALUE in a measure. if (start < 0 || end != start + 1 || embedId < 0) return null + if (!width.isFinite() || !height.isFinite()) return null if (width <= 0f || height <= 0f) return null return Embed(start, end, embedId, width, height) } /** - * The layout-affecting half, applied to the spannable the one builder - * produced. Each valid entry replaces its placeholder's glyph with a - * fixed `width` × `height` box that draws nothing — the overlay paints. + * Runs after the attribute spans and before the decorations, whose row padding adjusts line heights. + * Replaces JS's SP-decoded `lineHeight` twin with the DIP height, since a box ignores font scale, and never shrinks the line. + * The twin is the last line height covering exactly this character; any other covering line height is prose leading. + */ + internal fun applyLineHeights(out: Spannable, spec: Spec) { + forEachReserved(out, spec) { embed -> + var twin: RunLineHeightSpan? = null + var floorPx = 0f + for (span in out.getSpans(embed.start, embed.end, RunLineHeightSpan::class.java)) { + if (out.getSpanStart(span) == embed.start && out.getSpanEnd(span) == embed.end) { + twin?.let { floorPx = max(floorPx, it.lineHeightPx.toFloat()) } + twin = span + } else { + floorPx = max(floorPx, span.lineHeightPx.toFloat()) + } + } + twin?.let { out.removeSpan(it) } + val heightPx = PixelUtil.toPixelFromDIP(embed.heightDp) + out.setSpan( + RunLineHeightSpan(max(heightPx, floorPx)), + embed.start, + embed.end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + + /** The box draws nothing; the overlay paints the embed. */ + internal fun applySpans(out: Spannable, spec: Spec) { + forEachReserved(out, spec) { embed -> + out.setSpan( + RunEmbedSpan( + PixelUtil.toPixelFromDIP(embed.widthDp).toInt().coerceAtLeast(1), + PixelUtil.toPixelFromDIP(embed.heightDp).toInt().coerceAtLeast(1), + ), + embed.start, + embed.end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + } + + /** + * Also re-walked by `SelectableRunHostView.reportEmbedRects`, so an entry that reserved nothing reports nothing. * * THE CHARACTER GUARD IS THE SKEW GUARD: a span is only set where the * text really carries U+FFFC. Offsets were computed against the text JS @@ -84,21 +126,13 @@ object RunEmbeds { * box would corrupt what the reader sees, where skipping the entry only * costs the reservation. */ - internal fun applySpans(out: Spannable, spec: Spec) { + private inline fun forEachReserved(out: Spannable, spec: Spec, body: (Embed) -> Unit) { if (spec.embeds.isEmpty()) return val length = out.length for (embed in spec.embeds) { if (embed.start >= length || embed.end > length) continue if (out[embed.start] != PLACEHOLDER) continue - out.setSpan( - RunEmbedSpan( - PixelUtil.toPixelFromDIP(embed.widthDp).toInt().coerceAtLeast(1), - PixelUtil.toPixelFromDIP(embed.heightDp).toInt().coerceAtLeast(1), - ), - embed.start, - embed.end, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, - ) + body(embed) } } @@ -134,14 +168,8 @@ object RunEmbeds { * * HOW THE HEIGHT ACTUALLY LANDS. `getSize` asks for the height as ascent * (the box sits on the baseline, rising `heightPx` above it), but the final - * line extents belong to the `LineHeightSpan`s: JS sends a `lineHeight` - * attribute equal to the embed height over this same character, emitted - * AFTER the base attribute, and `RunAttributedText.build`'s insertion-order - * rule makes that `RunLineHeightSpan` the last word on the placeholder's - * line. Its surplus branch redistributes extra room evenly above and below - * the baseline, so the baseline may sit mid-line — which is why - * `reportEmbedRects` anchors the reported rect on `getLineTop`, never on - * baseline arithmetic against this span's ascent. + * line extents belong to the `LineHeightSpan`s (`RunEmbeds.applyLineHeights`). Surplus room is split around + * the baseline, so it may sit mid-line, which is why `reportEmbedRects` anchors on `getLineTop`. * * IMMUTABLE, AND THAT IS LOAD-BEARING: the spannable carrying this span is * shared across the measure thread and the widget (`RunLayoutCache.styledText` diff --git a/android/src/main/java/com/selectablemarkdown/RunLayoutCache.kt b/android/src/main/java/com/selectablemarkdown/RunLayoutCache.kt index 5b50651..21fbea6 100644 --- a/android/src/main/java/com/selectablemarkdown/RunLayoutCache.kt +++ b/android/src/main/java/com/selectablemarkdown/RunLayoutCache.kt @@ -3,6 +3,7 @@ package com.selectablemarkdown import android.content.ComponentCallbacks2 import android.content.Context import android.content.res.Configuration +import android.graphics.Typeface import android.text.Spannable import com.facebook.react.uimanager.DisplayMetricsHolder import com.facebook.yoga.YogaMeasureMode @@ -16,12 +17,8 @@ import java.util.concurrent.atomic.AtomicBoolean * returns byte-for-byte what a rebuild would have; the agreement doctrine in * `RunTextMeasure` is untouched, this just stops paying for it repeatedly. * - * WHY IT EXISTS. Under Fabric the C++ shadow node re-measures on every commit - * and `SelectableRunHostView.commitProps` builds the same styled string again - * on the UI thread; on paper the shadow node dirties on every prop batch. A - * streamed message recommits its settled runs many times per second with - * nothing about them changed, so the same Spannable and the same StaticLayout - * were being rebuilt from identical inputs on two threads. React Native's own + * WHY IT EXISTS. A streamed message recommits its settled runs many times per + * second unchanged, and both measure and `commitProps` rebuild. React Native's own * text stack solves this identically (TextLayoutManager's spannable cache plus * a TextMeasureCache keyed on the full layout constraints, capped at 1024). * @@ -33,10 +30,11 @@ import java.util.concurrent.atomic.AtomicBoolean * be added to the `Spec` wrappers (the key holds the lists, not the * wrappers). * - the window display metrics (`density`, `scaledDensity`), because build - * bakes them into the spans: every `AbsoluteSizeSpan` and + * bakes them into the spans: every `AbsoluteSizeSpan` and every attribute * `RunLineHeightSpan` goes through `PixelUtil.toPixelFromSP` (reads - * `scaledDensity`) and every margin/tab-stop through `toPixelFromDIP` - * (reads `density`), both off `DisplayMetricsHolder.getWindowDisplayMetrics()`. + * `scaledDensity`), and every margin, tab stop and embed size through + * `toPixelFromDIP` (reads `density`), + * both off `DisplayMetricsHolder.getWindowDisplayMetrics()`. * A FONT-SCALE CHANGE MUST MISS THE CACHE: without the metrics in the key, * every run after an accessibility font-size change would keep rendering * and measuring at the previous scale, silently, until eviction happened @@ -50,8 +48,8 @@ import java.util.concurrent.atomic.AtomicBoolean * onTrimMemory, is memory reclamation, not invalidation: every entry is pure * derived data, so dropping all of them costs rebuilds and nothing else.) * - * THREADING. `RunTextMeasure.measure` runs on paper's shadow thread and on - * Fabric's layout thread (through JNI); `commitProps` runs on the UI thread. + * THREADING. `RunTextMeasure.measure` runs on the layout thread, or the UI + * thread for a synchronous commit; `commitProps` runs on the UI thread. * Every map access is synchronized on the map — including gets, because with * `accessOrder = true` a get reorders the map. The lock is also what safely * publishes a built Spannable across threads; after `build` returns, nothing @@ -111,6 +109,7 @@ internal object RunLayoutCache { val density: Float, val scaledDensity: Float, val localeTag: String, + val typefaces: List, ) /** @@ -235,12 +234,13 @@ internal object RunLayoutCache { val metrics = DisplayMetricsHolder.getWindowDisplayMetrics() return Key( text, - attributes.attributes, + attributes.layoutAttributes, decorations.decorations, embeds.embeds, metrics.density, metrics.scaledDensity, Locale.getDefault().toLanguageTag(), + resolvedTypefaces(attributes.layoutAttributes), ) } @@ -258,9 +258,15 @@ internal object RunLayoutCache { val metrics = DisplayMetricsHolder.getWindowDisplayMetrics() return key.density == metrics.density && key.scaledDensity == metrics.scaledDensity && - key.localeTag == Locale.getDefault().toLanguageTag() + key.localeTag == Locale.getDefault().toLanguageTag() && + key.typefaces == resolvedTypefaces(key.attributes) } + private fun resolvedTypefaces(attributes: List): List = + attributes.mapNotNull { attribute -> + attribute.fontFamily?.let { RunTypefaces.resolve(it, attribute.fontWeight ?: 400, attribute.italic) } + } + /** * The styled string for a key: cached, or built by the one builder and * cached. Both the measure paths and the view come through here, which is diff --git a/android/src/main/java/com/selectablemarkdown/RunTextMeasure.kt b/android/src/main/java/com/selectablemarkdown/RunTextMeasure.kt index f5c52c1..4eefb94 100644 --- a/android/src/main/java/com/selectablemarkdown/RunTextMeasure.kt +++ b/android/src/main/java/com/selectablemarkdown/RunTextMeasure.kt @@ -4,7 +4,9 @@ import android.os.Build import android.text.Layout import android.text.Spannable import android.text.StaticLayout +import android.text.TextDirectionHeuristics import android.text.TextPaint +import android.view.View import android.widget.TextView import com.facebook.react.uimanager.PixelUtil import com.facebook.yoga.YogaMeasureMode @@ -16,37 +18,12 @@ import kotlin.math.ceil * The one place a run's text layout is configured, and the one place it is * measured. * - * WHY THIS OBJECT EXISTS. Three separate things have to agree about how a run - * lays out: the `TextView` that draws it, the paper shadow node that measures - * it for Yoga on the shadow thread, and — under Fabric — - * `SelectableRunHostViewManager.measure`, which the C++ shadow node calls - * across JNI from the layout thread. If they disagree the symptom is not a - * build error, it is text clipped at the bottom of a run: it looks like a - * rendering bug, it gets worse with every extra line, and no test in this - * repository can see it (docs/FABRIC-PLAN.md §4.3 specifies the instrumented - * test that could, and says plainly that it cannot run here). So agreement is - * not maintained by keeping three call sites in sync — there is one paint - * configuration and one `StaticLayout` construction, and all three sides go - * through this file. `RunAttributedText.build` is the matching guarantee for - * the styled string itself. `RunLayoutCache` memoizes both and does not bend - * any of this: it sits in FRONT of the one builder and this one measure, - * never beside them, so a cache hit returns what the miss path would have - * built from the same inputs. + * WHY THIS OBJECT EXISTS. The drawing `TextView` and the JNI `measure` must lay a run out + * identically, or text clips at the bottom of a run where no test here can see it, so both go + * through one paint configuration and one `StaticLayout` construction. * - * A FREE CONSEQUENCE WORTH STATING: because the paper shadow node and the - * Fabric measure override call the same function with the same inputs, a run - * does not change height when an app flips `newArchEnabled`. Whatever the two - * architectures disagree about, it is not this. + * UNITS. Everything here is in **pixels**, and the caller converts: * - * UNITS. Everything here is in **pixels**, on both architectures, and the two - * architectures differ in what they want back — which is why converting is the - * caller's job and not this object's: - * - * - Paper's Yoga tree is itself in pixels (`LayoutShadowNode` pushes - * `PixelUtil.toPixelFromDIP` values into Yoga, and React Native's own - * `ReactTextShadowNode.measure` hands `StaticLayout` the incoming width - * unconverted and returns raw layout pixels). So the paper shadow node - * returns what `measure` returns, untouched. * - Fabric's Yoga tree is in points. `FabricUIManager.measure` converts the * constraints to pixels on the way in — `getYogaSize` is * `PixelUtil.toPixelFromDIP(maxSize)` @@ -121,11 +98,15 @@ internal object RunTextMeasure { private const val BREAK_STRATEGY = Layout.BREAK_STRATEGY_HIGH_QUALITY private const val HYPHENATION_FREQUENCY = Layout.HYPHENATION_FREQUENCY_NONE + /** Pinned on both sides: an RTL `TextView` resolves FIRSTSTRONG_RTL, while `StaticLayout.Builder` uses FIRSTSTRONG_LTR. */ + private val TEXT_DIRECTION = TextDirectionHeuristics.FIRSTSTRONG_LTR + private const val VIEW_TEXT_DIRECTION = View.TEXT_DIRECTION_FIRST_STRONG_LTR + /** * Scratch paint for the measure path, one per thread rather than one per - * call. Per thread because `measure` runs concurrently on paper's shadow - * thread and Fabric's layout thread and TextPaint is not thread-safe; - * never handed across threads or out of this function. `configurePaint` + * call. Per thread because `measure` runs on the layout or the UI thread + * and TextPaint is not thread-safe; never handed across threads or out of + * this function. `configurePaint` * re-runs on every measure because textSize derives from the window * metrics, which move under a font-scale or density change; nothing else * on the paint is ever written — span measurement inside StaticLayout and @@ -158,6 +139,7 @@ internal object RunTextMeasure { */ fun configurePaint(paint: TextPaint, baseSizeSp: Float = TEXT_SIZE_SP) { paint.textSize = PixelUtil.toPixelFromSP(baseSizeSp) + paint.isElegantTextHeight = true // Set explicitly on every configuration, measure side and view side // alike, for two reasons at once: the measure side's scratch paint is // thread-local and would otherwise freeze the locale it was created @@ -180,28 +162,25 @@ internal object RunTextMeasure { * writing the paint directly is equivalent to `setTextSize` here and not a * shortcut around it. * - * The three knobs below are the ones `measure` also sets on its - * `StaticLayout`; they are set here and nowhere else so that "what the - * view is configured with" and "what was measured" cannot drift apart in - * a diff. `includeFontPadding` is `TextView`'s default and is stated - * anyway, because the measure side has to name it explicitly and a default - * that is only true on one side is not agreement. + * Every knob below is one `measure` also sets on its `StaticLayout`, so it is set here and + * nowhere else; `includeFontPadding` is stated anyway because the measure side must name it. * * The line-spacing pair is set to the identity for a second reason beyond * agreement: leading is owned entirely by `RunLineHeightSpan`, driven by * the `lineHeight` attribute on the wire, and a non-zero `lineSpacingExtra` * here would add to it invisibly on the drawn side only. + * + * Fallback line spacing is pinned: `TextView` enables it only for targetSdk 28+ apps, `StaticLayout.Builder` never. */ fun configureTextView(view: TextView) { configurePaint(view.paint) view.includeFontPadding = true view.setLineSpacing(0f, 1f) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - // Pre-M Android has one line-breaking algorithm and no way to - // select another, so there is nothing to synchronise there and the - // measure side leaves it alone for the same reason. - view.breakStrategy = BREAK_STRATEGY - view.hyphenationFrequency = HYPHENATION_FREQUENCY + view.textDirection = VIEW_TEXT_DIRECTION + view.breakStrategy = BREAK_STRATEGY + view.hyphenationFrequency = HYPHENATION_FREQUENCY + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + view.isFallbackLineSpacing = true } } @@ -248,15 +227,6 @@ internal object RunTextMeasure { return YogaMeasureOutput.make(0f, 0f) } - // The cache consult. The key captures every input the styled string - // and the paint read — text, both specs, and the window display - // metrics — so a hit is exactly what the code below would have - // produced; RunLayoutCache's header carries the argument, including - // why the metrics must be in the key. Streaming recommits — Fabric - // re-measures every run on every commit, paper's shadow node dirties - // on every prop batch — arrive here with identical inputs and - // identical constraints, and the second lookup turns that whole case - // into a map get. val key = RunLayoutCache.key(text, spec, decorations, embeds) RunLayoutCache.measurement(key, width, widthMode, height, heightMode)?.let { return it } @@ -280,28 +250,27 @@ internal object RunTextMeasure { } val wrapWidth = layoutWidth.toInt().coerceAtLeast(1) - val layout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - StaticLayout.Builder - .obtain(styled, 0, styled.length, paint, wrapWidth) - .setAlignment(Layout.Alignment.ALIGN_NORMAL) - .setLineSpacing(0f, 1f) - .setIncludePad(true) - .setBreakStrategy(BREAK_STRATEGY) - .setHyphenationFrequency(HYPHENATION_FREQUENCY) - .build() - } else { - // Pre-M: no builder and no break strategy to choose, on either - // side. The remaining parameters are positional — width, alignment, - // spacing multiplier, spacing add, includePad — and match the - // builder call above knob for knob. - @Suppress("DEPRECATION") - StaticLayout(styled, paint, wrapWidth, Layout.Alignment.ALIGN_NORMAL, 1f, 0f, true) + val builder = StaticLayout.Builder + .obtain(styled, 0, styled.length, paint, wrapWidth) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setLineSpacing(0f, 1f) + .setIncludePad(true) + .setBreakStrategy(BREAK_STRATEGY) + .setHyphenationFrequency(HYPHENATION_FREQUENCY) + .setTextDirection(TEXT_DIRECTION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setUseLineSpacingFromFallbacks(true) } + val layout = builder.build() + + // `SelectableRunHostView` pads its TextView by this same `edgePaddingPx`, so the text sits in the room measured here. + val edge = RunDecorations.edgePaddingPx(decorations, text.length) + val contentHeight = layout.height.toFloat() + edge.top.toFloat() + edge.bottom.toFloat() val measuredHeight = when (heightMode) { YogaMeasureMode.EXACTLY -> height - YogaMeasureMode.AT_MOST -> minOf(layout.height.toFloat(), height) - else -> layout.height.toFloat() + YogaMeasureMode.AT_MOST -> minOf(contentHeight, height) + else -> contentHeight } val output = YogaMeasureOutput.make(layoutWidth, measuredHeight) RunLayoutCache.putMeasurement(key, width, widthMode, height, heightMode, output) diff --git a/android/src/main/java/com/selectablemarkdown/RunTypefaces.kt b/android/src/main/java/com/selectablemarkdown/RunTypefaces.kt new file mode 100644 index 0000000..f935d36 --- /dev/null +++ b/android/src/main/java/com/selectablemarkdown/RunTypefaces.kt @@ -0,0 +1,40 @@ +package com.selectablemarkdown + +import android.content.Context +import android.content.res.AssetManager +import android.graphics.Typeface +import com.facebook.react.common.assets.ReactFontManager + +/** Resolves through `ReactFontManager`, as `` does: `TypefaceSpan(String)` knows only the system font map. */ +internal object RunTypefaces { + + /** `WEIGHT_BOLD` is also the cut React Native's `TypefaceStyle` uses when it picks a face file. */ + const val WEIGHT_NORMAL = 400 + const val WEIGHT_BOLD = 700 + + @Volatile + private var assets: AssetManager? = null + + fun install(context: Context) { + if (assets != null) return + assets = context.applicationContext.assets + } + + /** + * Uncached: `ReactFontManager` caches already, and a second cache would freeze a lazily registered family. + * Weight and slant go with the family because React Native picks the face file from all three. + * Serialized because `ReactFontManager` uses plain `HashMap`s and this runs on the UI and layout threads. + */ + fun resolve(family: String, weight: Int, italic: Boolean): Typeface { + val assetManager = assets ?: return Typeface.create(family, nearestStyle(weight, italic)) + val fromAssets: Typeface? = synchronized(this) { + ReactFontManager.getInstance().getTypeface(family, weight, italic, assetManager) + } + return fromAssets ?: Typeface.create(family, nearestStyle(weight, italic)) + } + + private fun nearestStyle(weight: Int, italic: Boolean): Int { + val bold = if (weight >= WEIGHT_BOLD) Typeface.BOLD else Typeface.NORMAL + return if (italic) bold or Typeface.ITALIC else bold + } +} diff --git a/android/src/main/java/com/selectablemarkdown/SelectableMarkdownModule.kt b/android/src/main/java/com/selectablemarkdown/SelectableMarkdownModule.kt index 764e85b..abdc442 100644 --- a/android/src/main/java/com/selectablemarkdown/SelectableMarkdownModule.kt +++ b/android/src/main/java/com/selectablemarkdown/SelectableMarkdownModule.kt @@ -27,19 +27,22 @@ import com.facebook.react.module.annotations.ReactModule * that read finds nothing and the parse fails on an app whose native * module was, a tick later, perfectly fine. * - * Why it returns Boolean and never throws: this runs on the JS thread during - * startup, and the causes it can hit (a Gradle/NDK misconfiguration, an ABI - * the host app filtered out, a JS runtime that is already gone) are things + * Why it reports an outcome and never throws: this runs on the JS thread + * during startup, and the causes it can hit (a Gradle/NDK misconfiguration, an + * ABI the host app filtered out, a JS runtime that is already gone) are things * only a rebuild can fix. Throwing here would take the whole app down at * launch for a fault no user action can clear, and would report it far from * its cause. So the reason goes to logcat for whoever is debugging the build - * and the outcome comes back as `false`. It is not a soft failure: md4c is - * the only parser this package ships, so a `false` that is never followed by + * and the outcome comes back as a string. It is not a soft failure: md4c is + * the only parser this package ships, so a refusal that is never followed by * a successful install means every non-empty document throws out of * parseDocument — with a message naming the missing native module — instead * of rendering. The view layer itself is unaffected, which is why this is * reported rather than fatal: the app runs, its markdown does not parse. * + * `installed` is done; `unavailable` (no JSI context or runtime yet) is transient, so ask again; + * `refused` (library, JNI symbol or C++ installer failed) is permanent for this runtime. + * * The @ReactModule annotation is what the new architecture reads the JS-facing * name from. ReactPackageTurboModuleManagerDelegate builds each package's * ReactModuleInfo map at startup and prefers `reactModule.name()` over @@ -53,10 +56,19 @@ class SelectableMarkdownModule(reactContext: ReactApplicationContext) : override fun getName(): String = MODULE_NAME + /** Per module instance, so a reload's fresh runtime is asked afresh; `unavailable` and success stay unmemoized. */ + private var refused = false + @ReactMethod(isBlockingSynchronousMethod = true) - fun install(): Boolean { + fun install(): String { + if (refused) { + return OUTCOME_REFUSED + } + if (!nativeLibraryLoaded) { - return false + // The `by lazy` below has already logged, once for the process. + refused = true + return OUTCOME_REFUSED } // The holder is absent before the JS runtime exists (and on a context @@ -66,7 +78,7 @@ class SelectableMarkdownModule(reactContext: ReactApplicationContext) : val contextHolder = reactApplicationContext.javaScriptContextHolder if (contextHolder == null) { Log.w(TAG, "install skipped: no JSI context holder (context not ready)") - return false + return OUTCOME_UNAVAILABLE } return try { @@ -85,9 +97,13 @@ class SelectableMarkdownModule(reactContext: ReactApplicationContext) : "install skipped: no JSI runtime pointer " + "(context tearing down, or running without a JSI runtime)", ) - false + OUTCOME_UNAVAILABLE + } else if (nativeInstall(runtimePointer)) { + OUTCOME_INSTALLED } else { - nativeInstall(runtimePointer) + // The C++ installer already logged why (OnLoad.cpp). + refused = true + OUTCOME_REFUSED } } } catch (error: Throwable) { @@ -97,7 +113,8 @@ class SelectableMarkdownModule(reactContext: ReactApplicationContext) : // else because no failure mode of an optional fast path is worth // taking the app down for. Log.w(TAG, "install failed: native install threw", error) - false + refused = true + OUTCOME_REFUSED } } @@ -116,6 +133,11 @@ class SelectableMarkdownModule(reactContext: ReactApplicationContext) : companion object { const val MODULE_NAME = "SelectableMarkdown" + /** Matched verbatim by src/engine/native/install.ts and returned alike by platform/ios/SelectableMarkdownModule.mm. */ + const val OUTCOME_INSTALLED = "installed" + const val OUTCOME_UNAVAILABLE = "unavailable" + const val OUTCOME_REFUSED = "refused" + private const val TAG = "SelectableMarkdown" private const val LIBRARY_NAME = "selectable-markdown" diff --git a/android/src/main/java/com/selectablemarkdown/SelectableRunHostView.kt b/android/src/main/java/com/selectablemarkdown/SelectableRunHostView.kt index cb01fcd..743f8e4 100644 --- a/android/src/main/java/com/selectablemarkdown/SelectableRunHostView.kt +++ b/android/src/main/java/com/selectablemarkdown/SelectableRunHostView.kt @@ -1,15 +1,20 @@ package com.selectablemarkdown import android.annotation.SuppressLint +import android.content.Context import android.os.Build import android.view.ActionMode import android.view.GestureDetector +import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.MotionEvent +import android.view.View +import android.view.accessibility.AccessibilityManager import android.view.ViewGroup import android.widget.FrameLayout import android.widget.TextView +import androidx.core.view.ViewCompat import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap @@ -26,8 +31,7 @@ import com.facebook.react.uimanager.UIManagerHelper * JS contract (see docs/SELECTION.md): * props: `text` (projected run text), `attributes` (styled ranges over * that text), `pressables` (tappable ranges over that text), - * `selectable`, `selectionActions` (ordered action identifiers: - * "copy-text" | "copy-markdown") + * `selectable`, `exclusiveSelection`, `selectionActions` * events: `onSelectionAction({ start, end, action, selectedText })` — * UTF-16 code-unit offsets into the CURRENT `text`, * end-exclusive, clamped, start <= end; `action` names the menu @@ -35,6 +39,9 @@ import com.facebook.react.uimanager.UIManagerHelper * `onInlinePress({ start, end, pressableId })` — a single tap * landed inside one of `pressables`; same offset guarantees, and * `pressableId` is JS's identifier for the range, echoed verbatim. + * `onSelectionChange({ start, end })` — deduped; same offset + * guarantees, except that an EMPTY range means nothing is selected. + * commands: `clearSelection()`, `setSelection(start, end)`, in the same offsets. * * The system Copy item (android.R.id.copy) is never intercepted, replaced, * or reordered: stock plain-text copy keeps working with no JS involvement. @@ -60,51 +67,69 @@ import com.facebook.react.uimanager.UIManagerHelper * settled flowing block into one run keyed on its start offset, so when the * next block settles it lands in the same run: same key, same mounted view, * still `selectable=true`, longer text. Measured across the eight shipped - * fixtures, an Android-selectable run's text changes about four times per - * streamed message, and each one costs the user their selection and the open - * action mode. + * fixtures, an Android-selectable run's text changes several times per + * streamed message, and each one costs the user their selection and the + * open action mode. * * Nothing here can copy the wrong markdown — the offsets are always read from * the current text — so this is a UX defect, not a correctness one. It is - * pre-existing and unfixed; docs/SELECTION.md ("Android — no preservation, and - * a known gap") carries the measurement and the two candidate fixes, both of - * which are larger than this file. + * pre-existing and unfixed; docs/SELECTION.md ("Android: no preservation, and + * a known gap") carries the measurement and the candidate fixes. * - * THE SAME OBJECT BACKS BOTH ARCHITECTURES. Nothing in this class is - * conditional on paper or Fabric: the view manager creates it either way, the - * props arrive either way (Fabric hands the Java ViewManager the raw props, so - * `RunAttributedText.parse` reads them unchanged), and both events go out - * through a dispatcher that resolves per architecture. What Fabric adds is - * recycling — see `prepareToRecycle`, which is a correctness requirement here - * and not hygiene. + * Fabric recycles views, so `prepareToRecycle` is a correctness requirement, not hygiene. */ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { /** - * The host's text widget. A subclass for one behaviour the platform only + * The host's text widget. A subclass for the behaviours the platform only * offers to a TextView subclass, mirroring the iOS host: * `onSelectionChanged` feeds the one-active-selection coordination (see - * `liveHosts`), the only selection-change signal a TextView exposes. + * `activeHost`), the only selection-change signal a TextView exposes, and + * the dispatch overrides below feed `ExploreByTouchHelper`. * * Everything else stays the stock widget — the decorator design's whole - * point — and the override degrades to `super` whenever it has nothing + * point — and every override degrades to `super` whenever it has nothing * to do. */ private inner class RunTextView(context: android.content.Context) : TextView(context) { override fun onSelectionChanged(selStart: Int, selEnd: Int) { super.onSelectionChanged(selStart, selEnd) - // The guard is also the constructor guard: TextView's own init - // reaches this override before the host's fields exist, always - // with an empty selection. - if (selEnd > selStart) { - clearOtherHostSelections() + // TextView's own constructor reaches this before the host's fields exist. + if (!readyForEvents) return + // Report before coordinating, so a hand-off reaches JS as the new range, then the old run's empty one. + emitSelectionChange() + if (selEnd > selStart && exclusiveSelection) { + becomeActiveSelectionHost() } } + + // The feeds ExploreByTouchHelper cannot install for itself. + override fun dispatchHoverEvent(event: MotionEvent): Boolean { + if (accessibilityHelper?.dispatchHoverEvent(event) == true) return true + return super.dispatchHoverEvent(event) + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (accessibilityHelper?.dispatchLinkKeyEvent(event) == true) return true + return super.dispatchKeyEvent(event) + } + + override fun onFocusChanged( + focused: Boolean, + direction: Int, + previouslyFocusedRect: android.graphics.Rect?, + ) { + super.onFocusChanged(focused, direction, previouslyFocusedRect) + accessibilityHelper?.onHostFocusChanged(focused, direction, previouslyFocusedRect) + } } private val textView: TextView = RunTextView(context) + /** Nullable: `RunTextView`'s dispatch overrides can run before `init` assigns it. */ + private var accessibilityHelper: RunAccessibilityHelper? = null + /** The three props the rendered text is built from, plus a dirty flag. * React Native delivers a whole prop batch and then calls * `onAfterUpdateTransaction`, so applying them there costs one setText @@ -119,6 +144,23 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { private var pendingEmbeds: RunEmbeds.Spec = RunEmbeds.Spec.EMPTY private var textDirty = false + /** Separate from `textDirty`: a pressables-only update changes the accessibility ranges, never the text. */ + private var accessibilityDirty = false + private val accessibilityManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager + private val accessibilityStateListener = AccessibilityManager.AccessibilityStateChangeListener { enabled -> + accessibilityDirty = true + if (enabled) commitProps() else accessibilityHelper?.setNodes(emptyList()) + } + + /** Read inside `RunTextView`'s constructor, where only the JVM default false makes that safe. */ + private var readyForEvents = false + + private var exclusiveSelection = true + + /** Empty selections are normalised to (0, 0), so both of Android's spellings dedupe and a never-selected host reports nothing. */ + private var lastReportedStart = 0 + private var lastReportedEnd = 0 + /** Last rect reported per embedId, in dp — the dedupe that keeps * streaming appends past a settled embed from re-announcing it on every * snapshot. Values only ever compared against the next report. */ @@ -131,9 +173,11 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { private val decorationPath = android.graphics.Path() private val decorationRadii = FloatArray(8) - /** Configured menu actions, in prop order (identifiers from JS). */ - private var selectionActions: List = - listOf(ACTION_COPY_TEXT, ACTION_COPY_MARKDOWN) + private var selectionActions: List = defaultSelectionActions() + + private val addedMenuItemIds = ArrayList(2) + + private val menuItemActions = HashMap() /** Tappable ranges over the text, parsed from the `pressables` prop. * Empty whenever JS has no listener, which is what keeps every touch @@ -179,33 +223,26 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { // Remove-first, then re-add per the current prop: repeated // prepare calls (OEM skins invoke it more than once) and prop // updates both converge on the same menu with no duplicates. - // System items (android.R.id.copy and friends) stay exactly - // where the platform put them. - menu.removeItem(ITEM_ID_COPY_TEXT) - menu.removeItem(ITEM_ID_COPY_MARKDOWN) + for (index in addedMenuItemIds.indices) { + menu.removeItem(addedMenuItemIds[index]) + } + addedMenuItemIds.clear() + menuItemActions.clear() var order = Menu.CATEGORY_SECONDARY for (action in selectionActions) { - val itemId = when (action) { - ACTION_COPY_TEXT -> ITEM_ID_COPY_TEXT - ACTION_COPY_MARKDOWN -> ITEM_ID_COPY_MARKDOWN - // Forward-compat: identifiers this binary does not know - // are ignored, never rendered as dead items. - else -> continue - } - menu.add(Menu.NONE, itemId, order, titleFor(action)) + // Sequential from the base, so the default two-item menu keeps its ids. + val itemId = ITEM_ID_BASE + addedMenuItemIds.size + menu.add(Menu.NONE, itemId, order, action.title) + addedMenuItemIds.add(itemId) + menuItemActions[itemId] = action.id order += 1 } return true } override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - val action = when (item.itemId) { - ITEM_ID_COPY_TEXT -> ACTION_COPY_TEXT - ITEM_ID_COPY_MARKDOWN -> ACTION_COPY_MARKDOWN - // Never intercept system items (android.R.id.copy etc.); - // plain copy must keep its stock behavior. - else -> return false - } + // Only ids this callback added are in the map, so system and OEM items decline by missing. + val action = menuItemActions[item.itemId] ?: return false emitSelectionAction(action) mode.finish() return true @@ -221,11 +258,12 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { init { textView.setTextIsSelectable(true) // Every knob that appears on both sides of the measure/draw boundary — - // text size, font padding, line spacing, line breaking — is set here + // text size, font padding, line spacing, line breaking, paragraph + // direction, fallback line spacing — is set here // and only here. RunTextMeasure owns them because the two things that // must agree about them are this TextView and the StaticLayout built - // by the measure path (paper's shadow node, or the C++ shadow node - // through SelectableRunHostViewManager.measure). Setting any of them + // by the measure path (the C++ shadow node, through + // SelectableRunHostViewManager.measure). Setting any of them // directly on this view again would be the drift that file exists to // prevent, and the symptom is text clipped at the bottom of a run. RunTextMeasure.configureTextView(textView) @@ -236,19 +274,28 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { // builds; markdown runs need none of its smart-selection output. textView.textClassifier = android.view.textclassifier.TextClassifier.NO_OP } + // ExploreByTouchHelper's constructor forces `focusable` and importantForAccessibility YES; + // both are restored, as React Native's ReactAccessibilityDelegate does. + val focusableBefore = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) textView.focusable + else if (textView.isFocusable) View.FOCUSABLE else View.NOT_FOCUSABLE + val importanceBefore = textView.importantForAccessibility + val helper = RunAccessibilityHelper(textView) { pressable -> emitInlinePress(pressable) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) textView.setFocusable(focusableBefore) + else textView.isFocusable = focusableBefore == View.FOCUSABLE + textView.importantForAccessibility = importanceBefore + ViewCompat.setAccessibilityDelegate(textView, helper) + accessibilityHelper = helper addView( textView, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) ) - // One-active-selection coordination — see `liveHosts`. Registered - // last, so a host in the registry always has fully initialised - // fields. - liveHosts.add(this) // A ViewGroup skips its own onDraw by default; block chrome (boxes, // rules) is painted there, behind the child TextView — which is the // stacking the design needs, since the platform draws the selection // highlight inside the TextView, above whatever this layer painted. setWillNotDraw(false) + // Last in `init`: everything `onSelectionChanged` touches is assigned by now. + readyForEvents = true } // ---- Props ------------------------------------------------------------- @@ -257,6 +304,7 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { if (value == pendingText) return pendingText = value textDirty = true + accessibilityDirty = true } /** @@ -266,9 +314,7 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { * dismisses the ActionMode even when the new value is character-identical * to the old one. * - * On the old architecture an unguarded setter was harmless: paper sends - * only the props JS diffed as changed, so this ran when `attributes` - * actually changed. Fabric does not diff. `FabricMountingManager::getProps` + * Fabric does not diff props. `FabricMountingManager::getProps` * returns `newShadowView.props->rawProps` whole * (ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.cpp:222-226), * `SurfaceMountingManager` wraps that entire map, and @@ -286,8 +332,10 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { */ fun setAttributes(spec: RunAttributedText.Spec) { if (spec.attributes == pendingAttributes.attributes) return + val layoutChanged = spec.layoutAttributes != pendingAttributes.layoutAttributes pendingAttributes = spec - textDirty = true + textDirty = textDirty || layoutChanged + accessibilityDirty = true } /** @@ -321,6 +369,13 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { /** Applied once per prop batch, from the view manager. */ fun commitProps() { + // Before the early return: a pressables-only batch leaves `textDirty` false. + if (accessibilityDirty && accessibilityManager.isEnabled) { + accessibilityDirty = false + accessibilityHelper?.setNodes( + RunAccessibility.resolve(pendingText, pendingAttributes, pressables) + ) + } if (!textDirty) return textDirty = false // No selection preservation, and the class comment is honest about @@ -349,6 +404,9 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { // the previous run's base size. The measure paths derive the same // value from the same two props (RunTextMeasure.baseTextSizeSp). RunTextMeasure.updateTextViewBaseSize(textView, pendingText, pendingAttributes) + // Padding, so every `totalPaddingTop` conversion here moves with it; `RunTextMeasure.measure` reserved the same pixels. + val edge = RunDecorations.edgePaddingPx(pendingDecorations, pendingText.length) + textView.setPadding(0, edge.top, 0, edge.bottom) textView.text = RunLayoutCache.styledText( RunLayoutCache.key(pendingText, pendingAttributes, pendingDecorations, pendingEmbeds) ) @@ -356,6 +414,8 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { // own display list is stale the moment the text moves — and a child // invalidation alone does not rebuild the parent's. invalidate() + // Called directly: whether `setText` re-notifies `onSelectionChanged` varies by version, and the dedupe makes a duplicate free. + emitSelectionChange() // Embed rects are read off the TextView's Layout, which does not // exist until the layout pass the setText above requested. onLayout // is the primary report point; the post is the belt for commits the @@ -370,6 +430,33 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { textView.setTextIsSelectable(value) } + /** + * False opts out both ways: this host never clears another's selection and is never recorded, so none clears its. + * Its selection stays live but unhighlighted once another view takes focus. + */ + fun setExclusiveSelection(value: Boolean) { + if (value == exclusiveSelection) return + exclusiveSelection = value + if (!value && activeHost?.get() === this) { + activeHost = null + } + } + + /** + * Takes focus, since a `TextView` draws its highlight only while focused, so a scrolling ancestor may follow. + * A range that clamps to empty is a no-op: it means a raced text swap, so the existing selection stays. + */ + fun setSelection(start: Int, end: Int) { + if (!textView.isTextSelectable) return + val spannable = textView.text as? android.text.Spannable ?: return + val length = spannable.length + val low = minOf(start, end).coerceIn(0, length) + val high = maxOf(start, end).coerceIn(0, length) + if (high <= low) return + textView.requestFocus() + android.text.Selection.setSelection(spannable, low, high) + } + /** * Deliberately independent of `text`/`attributes` and of `textDirty`: * what is tappable and what is drawn are separate channels, so a @@ -378,43 +465,111 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { * discipline exists). */ fun setPressables(value: List) { + if (value == pressables) return pressables = value + accessibilityDirty = true } + /** Resolved on arrival because OEM skins call `onPrepareActionMode` repeatedly while a selection is live. */ fun setSelectionActions(actions: List) { - if (actions == selectionActions) { + val resolved = ArrayList(actions.size) + for (index in actions.indices) { + val action = parseSelectionAction(actions[index]) + if (action != null) { + resolved.add(action) + } + } + if (resolved == selectionActions) { return } - selectionActions = actions + selectionActions = resolved // An open menu rebuilds through onPrepareActionMode. activeActionMode?.invalidate() } + /** Splits at the first U+001F, so a title may contain one; an entry titled by neither JS nor this library is dropped. */ + private fun parseSelectionAction(encoded: String): ResolvedAction? { + val separator = encoded.indexOf(ACTION_TITLE_SEPARATOR) + val id = if (separator < 0) encoded else encoded.substring(0, separator) + if (id.isEmpty()) { + return null + } + val sent = if (separator < 0) "" else encoded.substring(separator + 1) + val title = if (sent.isNotEmpty()) sent else defaultTitleFor(id) + if (title == null) { + return null + } + return ResolvedAction(id, title) + } + + /** A resource, not a literal, so a host app can override or translate the built-in titles. */ + private fun defaultTitleFor(id: String): String? = when (id) { + ACTION_COPY_TEXT -> context.getString(R.string.selectable_markdown_copy_text) + ACTION_COPY_MARKDOWN -> context.getString(R.string.selectable_markdown_copy_markdown) + else -> null + } + + private fun defaultSelectionActions(): List = listOfNotNull( + parseSelectionAction(ACTION_COPY_TEXT), + parseSelectionAction(ACTION_COPY_MARKDOWN), + ) + // ---- Selection coordination --------------------------------------------- /** * One active selection across the document: Android never clears one * TextView's selection because another began one, so a transcript of - * per-run hosts could show two highlights at once — only the newest with - * a live action mode. Called from `RunTextView.onSelectionChanged` the - * moment a non-empty selection lands here. Recursion-safe: clearing - * another host fires its `onSelectionChanged` with an empty selection, - * which returns at the guard. + * per-run hosts would keep several live selections, all but one invisible. + * + * Every non-empty selection passes here, so the predecessor is the only host that can hold one. */ - private fun clearOtherHostSelections() { - for (host in liveHosts.toList()) { - if (host === this) continue - val other = host.textView.text as? android.text.Spannable ?: continue - if (host.textView.selectionEnd > host.textView.selectionStart) { - android.text.Selection.removeSelection(other) - host.activeActionMode?.finish() - } - } + private fun becomeActiveSelectionHost() { + val previous = activeHost?.get() + if (previous === this) return + activeHost = java.lang.ref.WeakReference(this) + previous?.clearSelection() + } + + /** Selection before the mode, since finishing is the platform's response; a backward selection has start > end. */ + fun clearSelection() { + val start = textView.selectionStart + val end = textView.selectionEnd + if (start < 0 || end < 0 || start == end) return + val spannable = textView.text as? android.text.Spannable ?: return + android.text.Selection.removeSelection(spannable) + activeActionMode?.finish() } // ---- Event emission ---------------------------------------------------- + /** Unlike iOS, no forced re-announcement after a text swap: `setText` drops the selection here. */ + private fun emitSelectionChange() { + val length = textView.text?.length ?: 0 + val rawStart = textView.selectionStart + val rawEnd = textView.selectionEnd + var start = 0 + var end = 0 + if (rawStart >= 0 && rawEnd >= 0) { + start = minOf(rawStart, rawEnd).coerceIn(0, length) + end = maxOf(rawStart, rawEnd).coerceIn(0, length) + if (end <= start) { + start = 0 + end = 0 + } + } + if (start == lastReportedStart && end == lastReportedEnd) return + lastReportedStart = start + lastReportedEnd = end + + // Recorded before dispatch, so an undeliverable report still moves the dedupe off the previous range. + val reactContext = context as ReactContext + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return + dispatcher.dispatchEvent( + SelectionChangeEvent(UIManagerHelper.getSurfaceId(this), id, start, end) + ) + } + private fun emitSelectionAction(action: String) { val length = textView.text?.length ?: 0 // TextView reports UTF-16 offsets; they may be -1 (no selection) or @@ -476,8 +631,8 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { if (x < layout.getLineLeft(line) || x > layout.getLineRight(line)) return null val offset = layout.getOffsetForHorizontal(line, x) // Half-open containment, matching how LinkMovementMethod queries - // spans at an insertion offset: ranges never overlap (links cannot - // nest), so the first hit is the only hit. + // spans at an insertion offset: JS's resolveRunPressables keeps the + // ranges disjoint, so the first hit is the only hit. return pressables.firstOrNull { offset >= it.start && offset < it.end } } @@ -526,11 +681,9 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { * * GEOMETRY IS ANCHORED ON THE LINE, NOT THE BASELINE. The reservation is * ascent-shaped in `RunEmbedSpan`, but the final line extents belong to - * the embed-height `RunLineHeightSpan` JS sends over the same character, - * and its surplus branch re-centres the extra room around the baseline — - * so `getLineBaseline - height` can point above the line's top. The top - * of the placeholder's line IS the top of the reserved band, whatever - * the baseline did. + * the `RunLineHeightSpan` from `RunEmbeds.applyLineHeights`, whose surplus + * branch can move the baseline, so `getLineBaseline - height` can point + * above the line's top. * * The horizontal edge takes the smaller of the two `getPrimaryHorizontal` * answers: in an RTL paragraph the placeholder's leading edge is its @@ -552,7 +705,7 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { val textTop = (textView.top + textView.totalPaddingTop - textView.scrollY).toFloat() for (embed in embeds) { - // The same guards `RunEmbeds.applySpans` applied to the string: + // The guards `RunEmbeds.forEachReserved` applied at build time: // an entry that reserved nothing must report nothing. if (embed.start >= length || embed.end > length) continue if (text[embed.start] != RunEmbeds.PLACEHOLDER) continue @@ -662,9 +815,7 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { val textTop = textView.top.toFloat() + textView.totalPaddingTop val firstLine = layout.getLineForOffset(start) val lastLine = layout.getLineForOffset(end - 1) - // Padding extends into the blank separator lines around the block; - // the clamp keeps a box at the run's very edge inside this view - // instead of painted over a neighbour (the parent clips anyway). + // Clamped: a newer bundle may name more padding than `edgePaddingPx` reserved, and painting over a neighbour is worse. val top = (textTop + layout.getLineTop(firstLine) - PixelUtil.toPixelFromDIP(decoration.paddingTop)).coerceAtLeast(0f) val bottom = (textTop + layout.getLineBottom(lastLine) + @@ -790,6 +941,8 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { pendingText = "" pendingAttributes = RunAttributedText.Spec.EMPTY pendingDecorations = RunDecorations.Spec.EMPTY + // Geometry, not a prop: `commitProps` recomputes it for the next run. + textView.setPadding(0, 0, 0, 0) // Embeds and their report ledger go together: a recycled host that // kept either could report the PREVIOUS run's rects against the next // run's embedIds — the embed cousin of the stale-selection failure @@ -797,17 +950,29 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { pendingEmbeds = RunEmbeds.Spec.EMPTY lastEmbedRects.clear() textDirty = false - selectionActions = listOf(ACTION_COPY_TEXT, ACTION_COPY_MARKDOWN) + selectionActions = defaultSelectionActions() + addedMenuItemIds.clear() + menuItemActions.clear() // A fresh host has no tappable ranges; a recycled one keeping the // previous run's would turn arbitrary spots of the next run's prose // into links — the pressable cousin of the stale-selection failure // this method exists to prevent. pressables = emptyList() + accessibilityDirty = false + accessibilityHelper?.setNodes(emptyList()) + if (activeHost?.get() === this) { + activeHost = null + } + // A kept dedupe could swallow the next run's first selection report. + lastReportedStart = 0 + lastReportedEnd = 0 + exclusiveSelection = true } // ---- Lifecycle & platform-bug containment ------------------------------- override fun onDetachedFromWindow() { + accessibilityManager.removeAccessibilityStateChangeListener(accessibilityStateListener) // Close any live menu and uninstall the callback so a lingering // ActionMode cannot hold this view (and its ReactContext) after // unmount. @@ -819,11 +984,14 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { override fun onAttachedToWindow() { super.onAttachedToWindow() + accessibilityManager.addAccessibilityStateChangeListener(accessibilityStateListener) + accessibilityDirty = true + commitProps() // Detach is not unmount. A ScrollView or FlatList with // `removeClippedSubviews` detaches and re-attaches the very same view // as it scrolls, and without this the callback uninstalled above was // gone for good: the run stayed selectable but permanently lost - // "Copy Text" and "Copy Markdown", with no error to notice. + // every item `selectionActions` asked for, with no error to notice. textView.customSelectionActionModeCallback = selectionCallback } @@ -866,19 +1034,18 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { */ internal data class Pressable(val start: Int, val end: Int, val id: Int) + /** A data class: `setSelectionActions` compares lists by value to skip invalidating an open menu. */ + internal data class ResolvedAction(val id: String, val title: String) + companion object { const val ACTION_COPY_TEXT = "copy-text" const val ACTION_COPY_MARKDOWN = "copy-markdown" /** - * Every live host, weakly, for one-active-selection coordination — - * the Android twin of the iOS `liveHosts` NSHashTable. A weak set, - * so unmounted hosts fall out on their own; touched from the main - * thread only (selection changes and clears are both UI-thread - * events), so no synchronization is needed. + * Main thread only, so unsynchronized; weak, so an unmounted host is collected. + * Process-wide: separate trees clear each other unless one sets `exclusiveSelection={false}`. */ - private val liveHosts: MutableSet = - java.util.Collections.newSetFromMap(java.util.WeakHashMap()) + private var activeHost: java.lang.ref.WeakReference? = null /** * Parses the `pressables` prop, with `RunAttributedText.parse`'s @@ -911,15 +1078,10 @@ class SelectableRunHostView(context: ReactContext) : FrameLayout(context) { null } - /** Distinctive high item ids ("SM" + index). They can never equal - * the small sequential ids OEM menus and ACTION_PROCESS_TEXT items - * use, nor any android.R.id constant (those live in 0x0102xxxx). */ - private const val ITEM_ID_COPY_TEXT = 0x53_4D_01 - private const val ITEM_ID_COPY_MARKDOWN = 0x53_4D_02 + /** "SM" + index: clear of the small OEM and ACTION_PROCESS_TEXT ids and of android.R.id's 0x0102xxxx. */ + private const val ITEM_ID_BASE = 0x53_4D_01 - private fun titleFor(action: String): String = when (action) { - ACTION_COPY_TEXT -> "Copy Text" - else -> "Copy Markdown" - } + /** Must match the separator `src/view/selectionActions.ts` packs with. */ + private const val ACTION_TITLE_SEPARATOR = '\u001F' } } diff --git a/android/src/main/java/com/selectablemarkdown/SelectableRunHostViewManager.kt b/android/src/main/java/com/selectablemarkdown/SelectableRunHostViewManager.kt index 0644d14..34b99a1 100644 --- a/android/src/main/java/com/selectablemarkdown/SelectableRunHostViewManager.kt +++ b/android/src/main/java/com/selectablemarkdown/SelectableRunHostViewManager.kt @@ -16,16 +16,13 @@ import com.facebook.yoga.YogaMeasureMode import com.facebook.yoga.YogaMeasureOutput /** - * Manager for the `SelectableRunHost` component, on **both** architectures. - * * The name must match the string in `codegenNativeComponent('SelectableRunHost')` * (src/view/SelectableRunHostNativeComponent.ts): it is what * `FabricUIManager.measure` and the mounting layer route on, and what the C++ * component name resolves to. * - * FABRIC ONLY. The package's peer range starts at react-native 0.82, where - * the old architecture cannot be installed, so the Kotlin measuring shadow - * node this manager used to build and type is gone. The shadow node for + * FABRIC ONLY: the peer range starts at react-native 0.82, so there is no + * Kotlin measuring shadow node. The shadow node for * `SelectableRunHost` is C++ — `RNSMRunHostShadowNode` in platform/fabric, * registered through the component descriptor the app's generated * autolinking.cpp instantiates. Measurement arrives through `measure` below, @@ -39,7 +36,7 @@ import com.facebook.yoga.YogaMeasureOutput * * IMPLEMENTING THE GENERATED INTERFACE IS THE POINT OF THE CODEGEN SETUP. * `SelectableRunHostManagerInterface` is generated from the TypeScript spec by - * React Native's Gradle plugin, for libraries regardless of architecture + * React Native's Gradle plugin, for every library that applies it * (ReactPlugin.kt:91-93 with an onlyIf that passes for every * com.android.library). It is the only compile-time link between the spec and * these setters: rename a prop in TypeScript and this file stops compiling, @@ -56,11 +53,7 @@ class SelectableRunHostViewManager : * * With a delegate present, `ViewManager.updateProperties` routes each prop * through `delegate.setProperty` instead of reflecting over @ReactProp - * (ViewManager.java:82-89). That is the same set of props either way — the - * generated switch calls the interface methods this class implements, and - * its default branch hands the base view props to BaseViewManagerDelegate — - * so paper behaviour is unchanged and Fabric gets the generated path it - * expects. + * (ViewManager.java:82-89), which reaches the same props reflection would. */ private val delegate: ViewManagerDelegate = SelectableRunHostManagerDelegate(this) @@ -70,11 +63,9 @@ class SelectableRunHostViewManager : override fun getName(): String = COMPONENT_NAME override fun createViewInstance(reactContext: ThemedReactContext): SelectableRunHostView { - // Earliest point this component holds a Context: arm the cache's - // trim-memory reclamation (idempotent — one registration per process, - // on the application context, so it survives ReactInstance teardown - // the same way the cache singleton does). + // Idempotent, and kept on the application context so both outlive ReactInstance teardown. RunLayoutCache.installTrimHook(reactContext) + RunTypefaces.install(reactContext) return SelectableRunHostView(reactContext as ReactContext) } @@ -133,6 +124,22 @@ class SelectableRunHostViewManager : view.setSelectable(selectable) } + /** `defaultBoolean` must match the spec's `WithDefault` and the view's own field. */ + @ReactProp(name = "exclusiveSelection", defaultBoolean = true) + override fun setExclusiveSelection(view: SelectableRunHostView, exclusive: Boolean) { + view.setExclusiveSelection(exclusive) + } + + /** No `receiveCommand` override: `ViewManager` already forwards commands to the codegen delegate. */ + override fun clearSelection(view: SelectableRunHostView) { + view.clearSelection() + } + + override fun setSelection(view: SelectableRunHostView, start: Int, end: Int) { + // Clamped in the view, the only place that knows the current text. + view.setSelection(start, end) + } + @ReactProp(name = "pressables") override fun setPressables(view: SelectableRunHostView, pressables: ReadableArray?) { // Parsed on arrival, like `attributes`: the ReadableArray is bridge @@ -151,6 +158,7 @@ class SelectableRunHostViewManager : view.setEmbeds(RunEmbeds.parse(embeds)) } + /** Copied unexamined: the view unpacks `identifier + U+001F + title` (src/view/selectionActions.ts). */ @ReactProp(name = "selectionActions") override fun setSelectionActions(view: SelectableRunHostView, actions: ReadableArray?) { if (actions == null) { @@ -204,9 +212,7 @@ class SelectableRunHostViewManager : * JNI call is in points, so the result has to be converted back. React * Native's own text measurement ends with the same * `PixelUtil.toDIPFromPixel` pair (TextLayoutManager.java:692-711). - * Skipping it would report every run three times too tall on a 3x device; - * the paper path takes `RunTextMeasure`'s pixels straight through, because - * paper's Yoga tree is in pixels to begin with. + * Skipping it would report every run three times too tall on a 3x device. */ override fun measure( context: Context, @@ -219,6 +225,8 @@ class SelectableRunHostViewManager : heightMode: YogaMeasureMode, attachmentsPositions: FloatArray? ): Long { + // Fabric measures before mounting, so without this a bundled `fontFamily` measures in the system face. + RunTypefaces.install(context) val text = if (props != null && props.hasKey("text")) props.getString("text") ?: "" else "" val attributes = if (props != null && props.hasKey("attributes")) { RunAttributedText.parse(props.getArray("attributes")) @@ -294,6 +302,8 @@ class SelectableRunHostViewManager : mapOf("registrationName" to "onInlinePress") constants[EmbedLayoutEvent.EVENT_NAME] = mapOf("registrationName" to "onEmbedLayout") + constants[SelectionChangeEvent.EVENT_NAME] = + mapOf("registrationName" to "onSelectionChange") return constants } diff --git a/android/src/main/java/com/selectablemarkdown/SelectionChangeEvent.kt b/android/src/main/java/com/selectablemarkdown/SelectionChangeEvent.kt new file mode 100644 index 0000000..0bd3e74 --- /dev/null +++ b/android/src/main/java/com/selectablemarkdown/SelectionChangeEvent.kt @@ -0,0 +1,29 @@ +package com.selectablemarkdown + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +internal class SelectionChangeEvent( + surfaceId: Int, + viewId: Int, + private val start: Int, + private val end: Int, +) : Event(surfaceId, viewId) { + + override fun getEventName(): String = EVENT_NAME + + // Coalescing would drop a clear followed by a re-selection in one view within one frame. + override fun canCoalesce(): Boolean = false + + /** UTF-16 offsets, end-exclusive; `start == end` means nothing is selected. */ + override fun getEventData(): WritableMap = + Arguments.createMap().apply { + putInt("start", start) + putInt("end", end) + } + + companion object { + const val EVENT_NAME = "topSelectionChange" + } +} diff --git a/android/src/main/jni/CMakeLists.txt b/android/src/main/jni/CMakeLists.txt index ae0edd1..ea3a074 100644 --- a/android/src/main/jni/CMakeLists.txt +++ b/android/src/main/jni/CMakeLists.txt @@ -205,13 +205,8 @@ target_include_directories(react_codegen_SelectableMarkdownSpec BEFORE PUBLIC # FabricUIManager.measure as a ReadableMap. # # PROBED BY TARGET EXISTENCE, NEVER BY VERSION NUMBER. React Native 0.73-0.75 -# publish a prefab module per feature (reactnativejni among them); 0.76 -# collapsed the lot into a single `reactnative`. Both names are aliased into -# the app's CMake scope by ReactNative-application.cmake before it includes -# Android-autolinking.cmake, so exactly one of these branches is live and it is -# the right one — with no version string in this file to go stale. This is the -# same discipline ../../../CMakeLists.txt applies to ReactAndroid::jsi, and the -# reason that file needs no version ladder either. +# published `reactnativejni`; 0.76+ publishes `reactnative`, the only branch the +# peer range reaches. The old branch stays for consumers who override that range. # # Failing loudly rather than falling through: if neither target exists, the # link error would be a page of undefined facebook::jni symbols from a @@ -223,10 +218,11 @@ elseif(TARGET reactnative) target_link_libraries(react_codegen_SelectableMarkdownSpec reactnative) else() message(FATAL_ERROR - "react-native-selectable-markdown: neither the `reactnativejni` (React " - "Native 0.73-0.75) nor the `reactnative` (0.76+) prefab target is defined " - "in this scope. RNSMRunTextMeasurer.cpp needs react/jni/ReadableNativeMap.h " - "from one of them. If React Native renamed the module again, add the new " - "name as another branch here — the probe is deliberately by target " - "existence so this stays a one-line change.") + "react-native-selectable-markdown: neither the `reactnative` prefab target " + "(React Native 0.76+, which covers the whole supported range) nor the " + "historical `reactnativejni` (0.73-0.75) is defined in this scope. " + "RNSMRunTextMeasurer.cpp needs react/jni/ReadableNativeMap.h from one of " + "them. If React Native renamed the module again, add the new name as " + "another branch here — the probe is deliberately by target existence so " + "this stays a one-line change.") endif() diff --git a/android/src/main/jni/RNSMRunTextMeasurer.cpp b/android/src/main/jni/RNSMRunTextMeasurer.cpp index 0e224d6..da93a6c 100644 --- a/android/src/main/jni/RNSMRunTextMeasurer.cpp +++ b/android/src/main/jni/RNSMRunTextMeasurer.cpp @@ -18,9 +18,8 @@ * The consequence is the property docs/FABRIC-PLAN.md §4 cares most about: * measure/draw agreement on Android is not maintained by keeping two engines * configured identically, it is structural, because there is only one engine - * and both sides call it. Paper's `SelectableRunHostShadowNode` calls the same - * `RunTextMeasure.measure`, so a run does not even change height when an app - * flips `newArchEnabled`. + * and both sides call it — the shadow node through this file, and the mounted + * `SelectableRunHostView` directly. * * THIS FILE ENCODES NOTHING AND KNOWS NOTHING ABOUT ATTRIBUTES. It forwards * `props.rawProps` — the exact `folly::dynamic` of the JS props, populated by diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..158894d --- /dev/null +++ b/android/src/main/res/values/strings.xml @@ -0,0 +1,31 @@ + + + + Copy Text + Copy Markdown + diff --git a/bench/crossing.mjs b/bench/crossing.mjs index 85bbf78..493cfce 100644 --- a/bench/crossing.mjs +++ b/bench/crossing.mjs @@ -8,7 +8,8 @@ // allocation of the buffer that crosses. // (b) JS decode -> AST — decodeFlatBuffer over a PRE-COMPUTED buffer, so // nothing native is running inside the measurement. This is the JS half -// of the protocol: span widening, text slicing, entity decoding, policy. +// of the protocol: span widening, text slicing, the string table's UTF-8 +// decode, policy. Entities are resolved natively (OffsetParser.cpp), not here. // (c) total — the engine's real parse() call, (a) + (b) plus the glue. // // Reading it: if (a) dominates, the parser is the bottleneck and a faster @@ -53,7 +54,7 @@ const P = loadNativeProtocol(); const { decodeFlatBuffer, resolveOptions, presets } = lib; const options = resolveOptions(presets.llmChat); const extBits = P.extensionBits(options); -const htmlPolicy = P.htmlPolicyBit(options); +const htmlPolicy = P.htmlPolicyBit(); const rawParse = native.parse; const engine = native.engine; diff --git a/bench/gates.test.ts b/bench/gates.test.ts new file mode 100644 index 0000000..b537ef6 --- /dev/null +++ b/bench/gates.test.ts @@ -0,0 +1,216 @@ +/** Bench failure paths, run as subprocesses because the exit code is what CI gates on. */ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const repoRoot = path.resolve(__dirname, '..'); +const benchDir = path.join(repoRoot, 'bench'); +const sprintTranscript = path.join( + repoRoot, + 'conformance', + 'fixtures', + 'transcript-sprint-review.json', +); +const giantTranscript = path.join( + repoRoot, + 'conformance', + 'fixtures', + 'transcript-giant-list.json', +); + +interface Ran { + status: number | null; + stdout: string; + stderr: string; + output: string; +} + +const runNode = (args: readonly string[], env?: NodeJS.ProcessEnv): Ran => { + const result = spawnSync(process.execPath, [...args], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...process.env, ...env }, + timeout: 110_000, + }); + const stdout = result.stdout ?? ''; + const stderr = result.stderr ?? ''; + return { status: result.status, stdout, stderr, output: `${stdout}${stderr}` }; +}; + +const pathological = path.join(benchDir, 'pathological.mjs'); +const projection = path.join(benchDir, 'projection.mjs'); +const streaming = path.join(benchDir, 'streaming-replay.mjs'); + +// Skipped without dist/ and the addon: support.mjs would otherwise run a full build in the test. +const distReady = fs.existsSync(path.join(repoRoot, 'dist', 'engine', 'Engine.js')); +const addonReady = fs.existsSync( + path.join(repoRoot, 'build', `selectable-markdown.${process.platform}-${process.arch}.node`), +); +if (process.env.CI && (!distReady || !addonReady)) { + throw new Error('Benchmark tests require the built library and native addon in CI.'); +} +const describeBench = distReady && addonReady ? describe : describe.skip; +jest.setTimeout(120_000); + +describeBench('bench:pathological as a gate', () => { + it('reports rather than crashing when --runs asks for no samples', () => { + const ran = runNode([pathological, '--quick', '--runs', '0']); + + expect(ran.stderr).not.toContain('TypeError'); + expect(ran.stderr).not.toContain("reading 'stage'"); + expect(ran.status).toBe(0); + expect(ran.stdout).toContain('no samples'); + }); + + it('fails a --budget run that produced no samples, instead of passing over nothing', () => { + const ran = runNode([pathological, '--quick', '--runs', '0', '--budget', '1000']); + + expect(ran.status).toBe(1); + expect(ran.stdout).toContain('a gate over nothing is a failure'); + }); + + it('gates each stage at its own budget', () => { + const ran = runNode([pathological, '--quick', '--budget-segment', '0.0001']); + + expect(ran.status).toBe(1); + expect(ran.stdout).toMatch(/OVER\s+segment/); + expect(ran.stdout).toContain('budget exceeded'); + + // One override must not gate the other stages. + expect(ran.stdout).toMatch(/ok\s+parse/); + }); +}); + +describe('--require-engine', () => { + // Breaking the addon cannot reach this path (the harness rebuilds it), so the helper is probed. + const probe = (flags: readonly string[]): Ran => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rnsm-require-engine-')); + const file = path.join(dir, 'probe.mjs'); + const support = new URL(`file://${path.join(benchDir, 'support.mjs')}`).href; + fs.writeFileSync( + file, + `import { exitWithoutEngine } from ${JSON.stringify(support)};\n` + + "exitWithoutEngine('[probe]');\n", + ); + try { + return runNode([file, ...flags]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }; + + it('exits 0 without the flag — a laptop with no toolchain is not a regression', () => { + const ran = probe([]); + expect(ran.status).toBe(0); + expect(ran.stdout).toContain('pass --require-engine'); + }); + + it('exits 1 with the flag, so a CI gate cannot pass having measured nothing', () => { + const ran = probe(['--require-engine']); + expect(ran.status).toBe(1); + expect(ran.stderr).toContain('measured nothing'); + }); +}); + +describeBench('bench:projection as a gate', () => { + it('passes on the anchoring transcript and prints the threshold it gated on', () => { + const ran = runNode([projection, '--transcript', sprintTranscript, '--require-engine']); + + expect(ran.status).toBe(0); + expect(ran.stdout).toMatch(/gate: cached <= 1\.25x/); + expect(ran.stdout).toMatch(/growth {2}cached=/); + }); + + it('exits 1 when cached growth passes the limit', () => { + // This corpus never trips the limit, so a copy of the bench lowers it. + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'rnsm-growth-gate-')); + const copy = path.join(scratch, 'projection.mjs'); + const source = fs.readFileSync(projection, 'utf8').replace( + 'const GROWTH_LIMIT = 1.25;', + 'const GROWTH_LIMIT = 0.5;', + ).replace("'./support.mjs'", JSON.stringify(new URL(`file://${path.join(benchDir, 'support.mjs')}`).href)); + expect(source).toContain('const GROWTH_LIMIT = 0.5;'); + fs.writeFileSync(copy, source); + try { + const ran = runNode([copy, '--transcript', sprintTranscript]); + expect(ran.status).toBe(1); + expect(ran.stdout).toMatch(/growth {2}OVER/); + expect(ran.stderr).toContain('cached amplification grew'); + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } + }); + + it('has no gate to run under --quick, so it measures one size and prints no verdict', () => { + const ran = runNode([projection, '--quick', '--transcript', sprintTranscript]); + expect(ran.status).toBe(0); + expect(ran.stdout).toMatch(/ 1x cached doc= {3}1,162 /); + expect(ran.stdout).not.toMatch(/ 2x cached /); + expect(ran.stdout).not.toMatch(/growth {2}cached=/); + }); +}); + +describeBench('bench:streaming reports matched statistics', () => { + const ran = (): Ran => runNode([streaming, '--quick', '--transcript', sprintTranscript]); + + it('prints both sides of the ratio as the same statistic over the same count', () => { + const out = ran().stdout; + + const streamed = out.match( + /streamed\s+([\d.]+) ms = median of (\d+) replay\(s\), each the SUM of its (\d+) append times/, + ); + const naive = out.match( + /naive\s+([\d.]+) ms = median of (\d+) replay\(s\), each the SUM of (\d+) full reparses/, + ); + expect(streamed).not.toBeNull(); + expect(naive).not.toBeNull(); + + // --quick is one replay, and the transcript is 131 deltas. + expect([streamed![2], streamed![3]]).toEqual(['1', '131']); + expect([naive![2], naive![3]]).toEqual(['1', '131']); + }); + + it('prints a ratio that is the quotient of the two numbers beside it', () => { + const out = ran().stdout; + const ratio = Number(out.match(/incremental-vs-full reparse ratio: ([\d.]+)/)![1]); + const streamedMs = Number(out.match(/streamed\s+([\d.]+) ms = median/)![1]); + const naiveMs = Number(out.match(/naive\s+([\d.]+) ms = median/)![1]); + + // Relative, because both totals are printed rounded to two decimals. + const recomputed = streamedMs / naiveMs; + expect(Math.abs(ratio - recomputed) / recomputed).toBeLessThan(0.1); + }); +}); + +describeBench('bench:streaming as a gate', () => { + const giant = (flags: readonly string[]): Ran => + runNode([streaming, '--transcript', giantTranscript, '--repeat', '1', ...flags]); + + it('exits 1 when a chunk p99 passes its budget', () => { + const ran = giant(['--budget-chunk', '0.0001']); + + expect(ran.status).toBe(1); + expect(ran.stdout).toMatch(/OVER\s+chunk p99/); + expect(ran.stdout).toContain('budget exceeded'); + }, 120_000); + + it('leaves the number nobody budgeted ungated', () => { + const ran = giant(['--budget-chunk', '0.0001']); + expect(ran.stdout).toMatch(/off\s+finalize/); + }, 120_000); + + it('fails a budgeted run that timed nothing, instead of passing over zero samples', () => { + const ran = giant(['--max-chunks', '0', '--budget-chunk', '20']); + + expect(ran.status).toBe(1); + expect(ran.stdout).toContain('a gate over nothing is a failure'); + }, 120_000); + + it('reports rather than failing when no budget is passed', () => { + const ran = giant([]); + expect(ran.status).toBe(0); + expect(ran.stdout).toContain('chunks: 2484 (21927 UTF-16 units), 1 replay(s)'); + expect(ran.stdout).not.toContain('budget exceeded'); + }, 120_000); +}); diff --git a/bench/head-to-head.mjs b/bench/head-to-head.mjs index 8099f85..4ce45bf 100644 --- a/bench/head-to-head.mjs +++ b/bench/head-to-head.mjs @@ -46,10 +46,14 @@ import { loadLibrary, loadNativeEngine, numberFlag, + refuseEngineFlag, repoRoot, stats, } from './support.mjs'; +// Before the `--only` dispatch: this bench skips resolveEngine, which refuses for the others. +refuseEngineFlag(); + const replicas = numberFlag('replicas', 12); const iterations = numberFlag('iterations', hasFlag('quick') ? 3 : 20); const warmup = hasFlag('quick') ? 1 : 3; diff --git a/bench/pathological.mjs b/bench/pathological.mjs index 715b10a..394e14f 100644 --- a/bench/pathological.mjs +++ b/bench/pathological.mjs @@ -1,17 +1,28 @@ #!/usr/bin/env node // Pathological-input timing: adversarial documents that punish quadratic -// parsers. Reports per-case wall-clock; with --budget it becomes a DoS -// regression gate (exit 1 when any case exceeds the budget or crashes). +// parsers. Reports per-case, per-STAGE wall-clock; with --budget it becomes a +// DoS regression gate (exit 1 when any stage exceeds the budget or crashes). // -// Usage: node bench/pathological.mjs [--quick] [--budget MS] [--runs N] +// Usage: node bench/pathological.mjs [--quick] [--runs N] [--require-engine] +// [--budget MS] [--budget-parse MS] [--budget-repair MS] +// [--budget-segment MS] [--budget-project MS] // -// --budget turns the run into a gate: any case whose median exceeds it, or +// --budget turns the run into a gate: any stage whose median exceeds it, or // that throws, exits 1. Without it the cases are only reported, because the // absolute numbers depend on the machine and a bare `npm run bench:*` should // not fail on a busy laptop. +// +// `--budget-` overrides --budget for that stage; passing only overrides +// gates only those stages. +// +// --require-engine fails, instead of exiting 0, when the addon does not resolve. +// +// md4c is linear; the TypeScript stages after it are the ones worth gating. +// Streaming is gated by bench:streaming, not here. import { deepBlockquoteSource, + exitWithoutEngine, fmtBytes, fmtMs, hasFlag, @@ -27,14 +38,24 @@ const budgetMs = numberFlag('budget', undefined); const runs = numberFlag('runs', quick ? 1 : 3); const lib = loadLibrary(); -const { parseDocument, presets } = lib; +const { parseDocument, presets, projectRun, repairTail, resolveOptions, segmentRuns } = lib; const engine = await resolveEngine(lib, '[bench:pathological]'); -if (!engine) process.exit(0); +if (!engine) exitWithoutEngine('[bench:pathological]'); const scale = (full, small) => (quick ? small : full); +// Mirrors CLEAN_SEED in src/stream/StreamSession.ts, the only seed a clean anchor yields. +const CLEAN_SEED = { openFence: null, inMath: false }; + const cases = [ + { name: 'decoded entities in one paragraph', input: '中' + '…'.repeat(scale(65_536, 4096)), options: presets.commonmark }, + { name: 'smart quotes in one paragraph', input: '中' + '"a" '.repeat(scale(131_072, 8192)), options: { ...presets.commonmark, smartPunctuation: true } }, + { + name: 'less-than prose without a closer', + input: 'a < '.repeat(scale(12_000, 1_200)), + options: presets.commonmark, + }, { name: 'nested brackets', input: '['.repeat(scale(10_000, 1_000)) + 'core' + ']'.repeat(scale(10_000, 1_000)), @@ -57,17 +78,56 @@ const cases = [ }, ]; +/** Timed one by one; each `run` reads the state earlier stages left, so order matters. */ +const STAGES = [ + { + name: 'parse', + run: (c, state) => { + state.doc = parseDocument(c.input, c.options, engine.engine); + }, + }, + { + name: 'repair', + run: (c, state) => { + repairTail(c.input, CLEAN_SEED, state.resolved, undefined); + }, + }, + { + name: 'segment', + run: (c, state) => { + state.runs = segmentRuns(state.doc); + }, + }, + { + name: 'project', + run: (c, state) => { + for (const segment of state.runs) projectRun(segment, state.doc); + }, + }, +]; + +const budgets = new Map( + STAGES.map((stage) => [stage.name, numberFlag(`budget-${stage.name}`, budgetMs)]), +); +const gating = [...budgets.values()].some((ms) => ms !== undefined); + let anyOver = false; let anyCrash = false; +let anyVacuous = false; -console.log( - `pathological inputs (${runs} run(s) per case${quick ? ', quick' : ''}${ - budgetMs !== undefined ? `, budget ${budgetMs} ms` : '' - })`, -); +const budgetSummary = gating + ? `, budgets ${STAGES.map((stage) => { + const ms = budgets.get(stage.name); + return `${stage.name} ${ms === undefined ? 'off' : `${ms} ms`}`; + }).join(', ')}` + : ''; + +console.log(`pathological inputs (${runs} run(s) per case${quick ? ', quick' : ''}${budgetSummary})`); for (const c of cases) { - const times = []; + console.log(` ${c.name.padEnd(30)} ${fmtBytes(Buffer.byteLength(c.input, 'utf8')).padStart(9)}`); + + const times = new Map(STAGES.map((s) => [s.name, []])); let crash = null; // Deliberately no warmup: these inputs are about worst-case cold behaviour, @@ -75,35 +135,126 @@ for (const c of cases) { // the case — the remaining runs would only reproduce it, and the timings // collected before it are not comparable to a case that completed. for (let i = 0; i < runs && !crash; i += 1) { - const t0 = performance.now(); - try { - parseDocument(c.input, c.options, engine.engine); - times.push(performance.now() - t0); - } catch (err) { - crash = err; + const state = { resolved: resolveOptions(c.options), doc: null, runs: [] }; + for (const stage of STAGES) { + const t0 = performance.now(); + try { + stage.run(c, state); + } catch (err) { + crash = { stage: stage.name, err }; + break; + } + times.get(stage.name).push(performance.now() - t0); } } - console.log(` ${c.name.padEnd(30)} ${fmtBytes(Buffer.byteLength(c.input, 'utf8')).padStart(9)}`); - - let status; - let detail; - if (crash) { - anyCrash = true; - status = 'CRASH'; - detail = String(crash.message || crash).split('\n')[0]; - } else { - const median = percentile(times, 50); - const over = budgetMs !== undefined && median > budgetMs; + for (const stage of STAGES) { + const samples = times.get(stage.name); + // A throw outranks samples from earlier runs. + if (crash && crash.stage === stage.name) { + anyCrash = true; + console.log( + ` ${'CRASH'.padEnd(5)} ${stage.name.padEnd(8)} ${String(crash.err.message || crash.err).split('\n')[0]}`, + ); + continue; + } + if (samples.length === 0) { + if (crash) { + console.log(` ${'n/a'.padEnd(5)} ${stage.name.padEnd(8)} not reached — \`${crash.stage}\` threw`); + } else { + anyVacuous = true; + console.log( + ` ${'none'.padEnd(5)} ${stage.name.padEnd(8)} no samples — \`--runs ${runs}\` asked for none`, + ); + } + continue; + } + const median = percentile(samples, 50); + const budget = budgets.get(stage.name); + const over = budget !== undefined && median > budget; if (over) anyOver = true; - status = over ? 'OVER' : 'ok'; - detail = `median ${fmtMs(median)} (min ${fmtMs(Math.min(...times))}, max ${fmtMs(Math.max(...times))})`; + console.log( + ` ${(over ? 'OVER' : 'ok').padEnd(5)} ${stage.name.padEnd(8)} median ${fmtMs(median)} ` + + `(min ${fmtMs(Math.min(...samples))}, max ${fmtMs(Math.max(...samples))})` + + `${budget === undefined ? '' : ` vs ${budget} ms`}`, + ); + } +} + +// Repair scaling: `'x [ '` (unclosed link openers, once quadratic in `repairTail`) +// against `'x y '`, a same-length control, so the ratio isolates the bracket cost. + +const REPAIR_SCALE_NS = quick ? [500, 1_000, 2_000] : [500, 1_000, 2_000, 4_000, 8_000]; +const REPAIR_SCALE_OPTIONS = resolveOptions(presets.commonmark); + +/** + * Warmed, unlike the cases above: a cold first sample would skew the smallest n. + * `batch` averages calls in one timed region for the microsecond-scale control. + */ +const REPAIR_SCALE_SAMPLES = Math.max(runs, 5); + +function medianRepairMs(input, batch = 1) { + repairTail(input, CLEAN_SEED, REPAIR_SCALE_OPTIONS, undefined); + const samples = []; + for (let i = 0; i < REPAIR_SCALE_SAMPLES; i += 1) { + const t0 = performance.now(); + for (let j = 0; j < batch; j += 1) { + repairTail(input, CLEAN_SEED, REPAIR_SCALE_OPTIONS, undefined); + } + samples.push((performance.now() - t0) / batch); } - console.log(` ${status.padEnd(5)} ${engine.name.padEnd(10)} ${detail}`); + return percentile(samples, 50); +} + +const CONTROL_BATCH = 32; + +console.log(''); +console.log( + `repair scaling: unmatched brackets vs. a linear control ` + + `(${REPAIR_SCALE_SAMPLES} run(s) per size, after a warmup)`, +); + +const perBracket = []; +for (const n of REPAIR_SCALE_NS) { + const brackets = 'x [ '.repeat(n); + const control = 'x y '.repeat(n); + const bracketMs = medianRepairMs(brackets); + const controlMs = medianRepairMs(control, CONTROL_BATCH); + const ratio = controlMs > 0 ? bracketMs / controlMs : null; + perBracket.push({ n, msPerBracket: bracketMs / n }); + const repairBudget = budgets.get('repair'); + const over = repairBudget !== undefined && bracketMs > repairBudget; + if (over) anyOver = true; + console.log( + ` ${(over ? 'OVER' : 'ok').padEnd(5)} n=${String(n).padStart(5)} ` + + `${fmtBytes(Buffer.byteLength(brackets, 'utf8')).padStart(9)} ` + + // Microseconds: `fmtMs` would print every control as `0.00 ms`. + `brackets ${fmtMs(bracketMs)} control ${(controlMs * 1000).toFixed(0).padStart(4)} us ` + + `xctl ${ratio === null ? ' n/a' : ratio.toFixed(1).padStart(6)}`, + ); +} + +// Per-bracket cost, not `xctl`: the control's timer noise would swamp the drift. +const GROWTH_LIMIT = 2; +if (perBracket.length >= 2) { + const first = perBracket[0]; + const last = perBracket[perBracket.length - 1]; + const growth = last.msPerBracket / first.msPerBracket; + const grew = growth > GROWTH_LIMIT; + if (grew) anyOver = true; + console.log( + ` ${(grew ? 'OVER' : 'ok').padEnd(5)} cost per bracket ${growth.toFixed(2)}x ` + + `from n=${first.n} to n=${last.n} (flat ~1.00x is linear; ` + + `${(last.n / first.n).toFixed(0)}x would be quadratic)`, + ); } -if (budgetMs !== undefined && (anyOver || anyCrash)) { - console.log('budget exceeded — failing.'); +if (gating && (anyOver || anyCrash || anyVacuous)) { + console.log( + anyVacuous && !anyOver && !anyCrash + ? `no stage produced a sample (--runs ${runs}) — a gate over nothing is a failure.` + : 'budget exceeded — failing.', + ); process.exit(1); } if (anyCrash) { diff --git a/bench/projection.mjs b/bench/projection.mjs new file mode 100644 index 0000000..2af3fdf --- /dev/null +++ b/bench/projection.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +// Projection amplification: source characters handed to `projectRun` over a +// streamed message, per character of the message, at two document sizes. +// +// `cached` (what ships) growing past GROWTH_LIMIT per doubling exits 1. The +// counts are exact, so the gate holds on any machine; `ms` is for scale only. +// +// The giant-list transcript never anchors, so nothing settles and both +// pipelines stay high on it by construction. +// +// Usage: node bench/projection.mjs [--quick] [--transcript PATH] [--chunk N] +// [--require-engine] +// +// --quick measures one size, which leaves no growth ratio and nothing to gate. +// --require-engine fails, instead of exiting 0, when the addon or dist/ is missing. + +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import { + exitWithoutEngine, + fixtureDir, + flagValue, + hasFlag, + loadLibrary, + numberFlag, + refuseEngineFlag, + repoRoot, + resolveEngine, +} from './support.mjs'; + +refuseEngineFlag(); + +const quick = hasFlag('quick'); +const chunkSize = numberFlag('chunk', 18); +const DEFAULT_TRANSCRIPTS = ['transcript-sprint-review.json', 'transcript-giant-list.json']; +const given = flagValue('transcript', null); +const transcriptPaths = + given === null + ? DEFAULT_TRANSCRIPTS.map((f) => path.join(fixtureDir, f)) + : [path.resolve(given)]; + +const lib = loadLibrary(); +const { StreamSession, presets, projectRun, segmentRuns } = lib; + +// Not in loadLibrary's Node-safe list, but react-native-free, so required from dist/. +const require = createRequire(import.meta.url); +const viewDir = path.join(repoRoot, 'dist', 'view'); +const cachePath = path.join(viewDir, 'projectionCache.js'); +if (!existsSync(cachePath)) { + console.log( + '[bench:projection] dist/ predates src/view/projectionCache.ts — run `npm run build` first.', + ); + exitWithoutEngine('[bench:projection]', 'dist/ has no view/projectionCache.js to measure'); +} +const { createRunProjectionCache } = require(cachePath); +const { runKey } = require(path.join(viewDir, 'runIdentity.js')); + +const engine = await resolveEngine(lib, '[bench:projection]'); +if (!engine) exitWithoutEngine('[bench:projection]'); + +/** A pure EmbedLookup that claims nothing and sums the top-level spans it sees. */ +function meter() { + let counting = false; + let chars = 0; + return { + embed: (node, context) => { + if (counting && context.topLevel) chars += node.span.end - node.span.start; + return undefined; + }, + measure(body) { + counting = true; + try { + return body(); + } finally { + counting = false; + } + }, + chars: () => chars, + }; +} + +function replay(deltas, cached) { + const gauge = meter(); + const caches = new Map(); + const session = new StreamSession({ engine: engine.engine, options: presets.llmChat }); + let worst = 0; + let commits = 0; + let runsAtEnd = 0; + + const draw = () => { + const snapshot = session.snapshot(); + const doc = snapshot.document; + const runs = segmentRuns(doc, { + settledUntil: snapshot.settledUntil, + embed: gauge.embed, + }); + runsAtEnd = runs.length; + commits += 1; + runs.forEach((run, index) => { + if (run.standalone) return; + const unsettledTail = + snapshot.phase === 'streaming' && run.span.end > snapshot.settledUntil; + const key = runKey(run, index, runs.length, unsettledTail); + const before = gauge.chars(); + if (cached) { + let cache = caches.get(key); + if (cache === undefined) { + cache = createRunProjectionCache(); + caches.set(key, cache); + } + gauge.measure(() => cache.project(run, doc, { embed: gauge.embed })); + } else { + gauge.measure(() => projectRun(run, doc, { embed: gauge.embed })); + } + worst = Math.max(worst, gauge.chars() - before); + }); + }; + + const started = performance.now(); + for (const delta of deltas) { + session.append(delta); + draw(); + } + session.finalize(); + draw(); + const ms = performance.now() - started; + + return { + projected: gauge.chars(), + source: session.snapshot().document.source.length, + worst, + commits, + runsAtEnd, + ms, + }; +} + +/** Re-chunked to `chunkSize`, because amplification depends on delta size. */ +function deltasOf(transcriptPath, replicas) { + const transcript = JSON.parse(readFileSync(transcriptPath, 'utf8')); + let text = ''; + for (let i = 0; i < replicas; i += 1) { + if (i > 0) text += '\n\n'; + text += transcript.deltas.join(''); + } + const out = []; + for (let at = 0; at < text.length; at += chunkSize) { + out.push(text.slice(at, at + chunkSize)); + } + return out; +} + +function fmt(n) { + return n.toLocaleString('en-US'); +} + +console.log( + `[bench:projection] engine=md4c chunk=${chunkSize} — projected characters per document character\n`, +); + +/** Linear reuse measures ~1.00 per doubling; the pipeline it replaced, ~2.00. */ +const GROWTH_LIMIT = 1.25; +const overGrowth = []; + +for (const transcriptPath of transcriptPaths) { + const name = path.basename(transcriptPath); + console.log(name); + const sizes = quick ? [1] : [1, 2]; + const amps = { cached: [], full: [] }; + + for (const replicas of sizes) { + const deltas = deltasOf(transcriptPath, replicas); + for (const mode of ['cached', 'full']) { + const r = replay(deltas, mode === 'cached'); + const amp = r.projected / r.source; + amps[mode].push(amp); + console.log( + ` ${String(replicas).padStart(2)}x ${mode.padEnd(6)} ` + + `doc=${fmt(r.source).padStart(8)} projected=${fmt(r.projected).padStart(11)} ` + + `projected/doc=${amp.toFixed(1).padStart(7)} worst=${fmt(r.worst).padStart(7)} ` + + `runs=${String(r.runsAtEnd).padStart(3)} ${r.ms.toFixed(1)}ms`, + ); + } + } + + if (sizes.length > 1) { + const growth = (mode) => amps[mode][1] / amps[mode][0]; + const cachedGrowth = growth('cached'); + const over = cachedGrowth > GROWTH_LIMIT; + if (over) overGrowth.push({ name, growth: cachedGrowth }); + console.log( + ` growth ${over ? 'OVER ' : ''}cached=${cachedGrowth.toFixed(2)}x ` + + `full=${growth('full').toFixed(2)}x ` + + ` (per doubling; ~1.00 is linear, ~2.00 is quadratic; gate: cached <= ${GROWTH_LIMIT.toFixed(2)}x)`, + ); + } + console.log(''); +} + +console.log( + 'Read `cached` as the shipped pipeline and `full` as what it replaced. The gate is\n' + + `that \`cached\` growth stays at or under ${GROWTH_LIMIT.toFixed(2)}x per doubling — projection work must\n` + + 'track the deltas, not the document they accumulate into. Exceeding it exits 1.\n' + + 'docs/PERFORMANCE.md carries the invariant.\n' + + '\nA document that never anchors has no settled blocks to reuse, so both columns\n' + + 'stay high for the giant-list transcript — the reuse is real, the anchoring is\n' + + 'what it depends on. The GATE is the growth column, not the absolute\n' + + 'amplification: an unanchored stream is expensive on both pipelines by\n' + + 'construction, and gating that number would only pin the fixture.', +); + +if (overGrowth.length > 0) { + for (const { name, growth } of overGrowth) { + console.error( + `[bench:projection] ${name}: cached amplification grew ${growth.toFixed(2)}x per doubling, ` + + `over the ${GROWTH_LIMIT.toFixed(2)}x limit — incremental projection is no longer reusing ` + + 'settled blocks. Start at src/view/projectionCache.ts and runKey in src/view/runIdentity.ts.', + ); + } + process.exit(1); +} diff --git a/bench/streaming-replay.mjs b/bench/streaming-replay.mjs index 0316dd9..a9da781 100644 --- a/bench/streaming-replay.mjs +++ b/bench/streaming-replay.mjs @@ -8,8 +8,12 @@ // - parse-input size: how many characters the engine actually read per // append (tail-only reparse means this tracks the unsettled tail, not // the accumulated document; construct-free appends skip the engine); -// - incremental-vs-full reparse ratio: total streamed append time versus -// what a naive reparse-on-every-token renderer would pay. +// - incremental-vs-full reparse ratio: the streamed path against a naive +// reparse-on-every-token renderer, both the median of per-replay totals. +// +// Two transcripts by default: sprint-review anchors constantly; giant-list is one +// bullet list that never anchors, so every append reparses the whole text. +// `--transcript PATH` narrows the run to one file. // // Per-chunk cost is the library's actual differentiator, and it has to hold up // on the engine that ships: a parser that is fast cold can still lose here if @@ -17,17 +21,26 @@ // bench/crossing.mjs). That is the pairing to read this file with. // // Usage: node bench/streaming-replay.mjs [--quick] [--transcript PATH] -// [--repeat N] [--max-chunks N] [--replicas N] +// [--repeat N] [--max-chunks N] [--replicas N] [--require-engine] +// [--budget MS] [--budget-chunk MS] [--budget-finalize MS] // // --replicas N streams the transcript N times back-to-back (separated by a // blank-line delta) as ONE growing session: the naive baseline's cost grows // with the accumulated document while tail-only parsing stays flat, so the // ratio shrinks as the stream gets longer. +// +// --budget MS sets both gated numbers, per transcript: --budget-chunk gates the +// p99 append (one GC pause in 2484 chunks is not a regression) and +// --budget-finalize the final clean parse. Without a budget nothing fails. +// +// --require-engine fails, instead of exiting 0, when the addon or the +// StreamSession probe does not resolve. import { readFileSync } from 'node:fs'; import path from 'node:path'; import { + exitWithoutEngine, fixtureDir, fmtMs, hasFlag, @@ -39,17 +52,29 @@ import { } from './support.mjs'; const quick = hasFlag('quick'); -const transcriptPath = path.resolve( - flagValue('transcript', path.join(fixtureDir, 'transcript-sprint-review.json')), -); +const DEFAULT_TRANSCRIPTS = ['transcript-sprint-review.json', 'transcript-giant-list.json']; +const given = flagValue('transcript', null); +const transcriptPaths = + given === null + ? DEFAULT_TRANSCRIPTS.map((f) => path.join(fixtureDir, f)) + : [path.resolve(given)]; const repeat = numberFlag('repeat', quick ? 1 : 3); const maxChunks = numberFlag('max-chunks', quick ? 150 : Infinity); +const replicas = numberFlag('replicas', 1); + +const budgetMs = numberFlag('budget', undefined); +const chunkBudgetMs = numberFlag('budget-chunk', budgetMs); +const finalizeBudgetMs = numberFlag('budget-finalize', budgetMs); +const gating = chunkBudgetMs !== undefined || finalizeBudgetMs !== undefined; + +let anyOver = false; +let anyVacuous = false; const lib = loadLibrary(); const { StreamSession, parseDocument, presets, visit } = lib; const engine = await resolveEngine(lib, '[bench:streaming]'); -if (!engine) process.exit(0); +if (!engine) exitWithoutEngine('[bench:streaming]'); // StreamSession gets its own probe on top of the engine probe resolveEngine // already did: a session that cannot take even one character is a different @@ -61,16 +86,7 @@ try { probe.finalize(); } catch (err) { console.log(`[bench:streaming] StreamSession unavailable (${err.message}) — nothing to measure yet.`); - process.exit(0); -} - -const replicas = numberFlag('replicas', 1); -const transcript = JSON.parse(readFileSync(transcriptPath, 'utf8')); -const baseDeltas = transcript.deltas.slice(0, maxChunks); -const deltas = []; -for (let i = 0; i < replicas; i++) { - if (i > 0) deltas.push('\n\n'); - deltas.push(...baseDeltas); + exitWithoutEngine('[bench:streaming]', `StreamSession threw on a one-character append (${err.message})`); } function collectNodes(doc) { @@ -81,133 +97,210 @@ function collectNodes(doc) { return seen; } -const out = { - chunkTimes: [], - changedCounts: [], - // Parse-input sizes for the LAST replay: appends only (finalize's single - // full clean parse is reported separately). - parseInputs: [], - finalizeInput: 0, - finalizeMs: 0, - totalMs: 0, - finalSnapshot: null, -}; - -/** - * One full replay of the transcript through a fresh session, timing each - * append. The engine is wrapped so the bench can see how many characters the - * splice actually handed the parser — that count, not the accumulated - * document length, is what tail-only reparse is supposed to keep small. - */ -function replay(record) { - const inputs = []; - const recordingEngine = { - name: `recording(${engine.name})`, - parse(source, options) { - inputs.push(source.length); - return engine.engine.parse(source, options); - }, +function runTranscript(transcriptPath) { + const transcript = JSON.parse(readFileSync(transcriptPath, 'utf8')); + const baseDeltas = transcript.deltas.slice(0, maxChunks); + const deltas = []; + for (let i = 0; i < replicas; i++) { + if (i > 0) deltas.push('\n\n'); + deltas.push(...baseDeltas); + } + + const out = { + chunkTimes: [], + replayTotals: [], + changedCounts: [], + // The last recorded replay's appends; finalize's parse is reported separately. + parseInputs: [], + finalizeInput: 0, + finalizeMs: 0, + totalMs: 0, + finalSnapshot: null, }; - const session = new StreamSession({ - engine: recordingEngine, - options: presets.llmChat, - }); - let previousNodes = new Set(); - const runStart = performance.now(); - - for (const delta of deltas) { - const t0 = performance.now(); - session.append(delta); - const ms = performance.now() - t0; - - const doc = session.snapshot().document; - const nodes = collectNodes(doc); - let changed = 0; - for (const node of nodes) { - if (!previousNodes.has(node)) changed += 1; + + /** One replay through a fresh session, with the engine wrapped to count parse input. */ + const replay = (record) => { + const inputs = []; + const recordingEngine = { + name: `recording(${engine.name})`, + parse(source, options) { + inputs.push(source.length); + return engine.engine.parse(source, options); + }, + }; + const session = new StreamSession({ + engine: recordingEngine, + options: presets.llmChat, + }); + let previousNodes = new Set(); + const runStart = performance.now(); + let appendTotal = 0; + + for (const delta of deltas) { + const t0 = performance.now(); + session.append(delta); + const ms = performance.now() - t0; + appendTotal += ms; + + const doc = session.snapshot().document; + const nodes = collectNodes(doc); + let changed = 0; + for (const node of nodes) { + if (!previousNodes.has(node)) changed += 1; + } + previousNodes = nodes; + if (record) { + out.chunkTimes.push(ms); + out.changedCounts.push(changed); + } } - previousNodes = nodes; + + const appendInputs = inputs.slice(); + const f0 = performance.now(); + session.finalize('end'); + const finalizeMs = performance.now() - f0; if (record) { - out.chunkTimes.push(ms); - out.changedCounts.push(changed); + out.replayTotals.push(appendTotal); + out.parseInputs = appendInputs; + out.finalizeMs = finalizeMs; + out.totalMs = performance.now() - runStart; + out.finalSnapshot = session.snapshot(); + out.finalizeInput = inputs.length > appendInputs.length ? inputs[inputs.length - 1] : 0; } - } + }; + + // Untimed warmup, matching the naive baseline's below. + replay(false); - const appendInputs = inputs.slice(); - const f0 = performance.now(); - session.finalize('end'); - const finalizeMs = performance.now() - f0; - if (record) { - out.parseInputs = appendInputs; - out.finalizeMs = finalizeMs; - out.totalMs = performance.now() - runStart; - out.finalSnapshot = session.snapshot(); - out.finalizeInput = inputs.length > appendInputs.length ? inputs[inputs.length - 1] : 0; + for (let r = 0; r < repeat; r += 1) replay(true); + + const totalChars = deltas.reduce((acc, d) => acc + d.length, 0); + const effectiveAppends = deltas.filter((d) => d.length > 0).length; + + const finalSource = out.finalSnapshot ? out.finalSnapshot.document.source : deltas.join(''); + + // Naive baseline: same engine and same estimator (median of per-replay sums) + // as the streamed side, so the ratio isolates the incremental strategy. + const naiveTotals = []; + // Untimed warmup replay, the same amount the streamed side got. + for (let i = 0; i < deltas.length; i += 1) { + parseDocument(finalSource, presets.llmChat, engine.engine); + } + for (let r = 0; r < repeat; r += 1) { + let total = 0; + for (let i = 0; i < deltas.length; i += 1) { + const t0 = performance.now(); + parseDocument(finalSource, presets.llmChat, engine.engine); + total += performance.now() - t0; + } + naiveTotals.push(total); } -} -// Warmup replay (untimed) so the session path is JIT-compiled before -// measurement, matching the warmup the naive-baseline loop gets below. -replay(false); - -for (let r = 0; r < repeat; r += 1) replay(true); - -const totalChars = deltas.reduce((acc, d) => acc + d.length, 0); -const effectiveAppends = deltas.filter((d) => d.length > 0).length; - -// Resolved once, outside every timed region: reading it out of the snapshot -// inside the loop would put a property walk inside the measurement. -const finalSource = out.finalSnapshot ? out.finalSnapshot.document.source : deltas.join(''); - -// Incremental-vs-full reparse ratio: total streamed append time versus what a -// naive reparse-on-every-token renderer would pay (chunks × full reparse of -// the final document). Lower is better; 1.0 means no win over naive reparse. -// With tail-only reparse + the construct-free fast path this should sit far -// below 1 and shrink as documents grow. The baseline is measured with the -// SAME engine the streamed numbers came from, so what the ratio isolates is -// the incremental strategy and nothing else — a naive loop timed on some -// other parser would just be a parser comparison wearing a different name. -const fullRuns = quick ? 3 : 10; -const fullTimes = []; -parseDocument(finalSource, presets.llmChat, engine.engine); // warmup -for (let i = 0; i < fullRuns; i += 1) { - const t0 = performance.now(); - parseDocument(finalSource, presets.llmChat, engine.engine); - fullTimes.push(performance.now() - t0); -} + const replicaNote = replicas > 1 ? ` × ${replicas} replicas` : ''; + console.log(`streaming replay: ${transcript.name ?? path.basename(transcriptPath)}${replicaNote}`); + console.log(` chunks: ${deltas.length} (${totalChars} UTF-16 units), ${repeat} replay(s)${quick ? ' [quick]' : ''}`); + + const t = stats(out.chunkTimes); + const c = stats(out.changedCounts); + const streamed = stats(out.replayTotals); + const naive = stats(naiveTotals); + const reparseRatio = naive.p50 > 0 ? streamed.p50 / naive.p50 : NaN; -const replicaNote = replicas > 1 ? ` × ${replicas} replicas` : ''; -console.log(`streaming replay: ${transcript.name ?? path.basename(transcriptPath)}${replicaNote}`); -console.log(` chunks: ${deltas.length} (${totalChars} UTF-16 units), ${repeat} replay(s)${quick ? ' [quick]' : ''}`); - -const t = stats(out.chunkTimes); -const c = stats(out.changedCounts); -const fullMs = stats(fullTimes).p50; -const streamedAppendMs = out.chunkTimes.reduce((acc, ms) => acc + ms, 0) / repeat; -const naiveMs = fullMs * deltas.length; -const reparseRatio = naiveMs > 0 ? streamedAppendMs / naiveMs : NaN; - -console.log(` engine: ${engine.name}`); -console.log(` ms/chunk: p50 ${fmtMs(t.p50)} | p95 ${fmtMs(t.p95)} | p99 ${fmtMs(t.p99)} | max ${fmtMs(t.max)}`); -console.log( - ` changed-identity nodes/chunk: p50 ${c.p50} | p95 ${c.p95} | p99 ${c.p99} | max ${c.max} | mean ${c.mean.toFixed(1)}`, -); -console.log(` last replay: ${fmtMs(out.totalMs)} total, finalize ${fmtMs(out.finalizeMs)}`); -if (out.parseInputs.length > 0) { - const pi = stats(out.parseInputs); - const fastPathAppends = effectiveAppends - out.parseInputs.length; + console.log(` engine: ${engine.name}`); + console.log(` ms/chunk: p50 ${fmtMs(t.p50)} | p95 ${fmtMs(t.p95)} | p99 ${fmtMs(t.p99)} | max ${fmtMs(t.max)}`); + console.log( + ` changed-identity nodes/chunk: p50 ${c.p50} | p95 ${c.p95} | p99 ${c.p99} | max ${c.max} | mean ${c.mean.toFixed(1)}`, + ); + console.log(` last replay: ${fmtMs(out.totalMs)} total, finalize ${fmtMs(out.finalizeMs)}`); + if (out.parseInputs.length > 0) { + const pi = stats(out.parseInputs); + const fastPathAppends = effectiveAppends - out.parseInputs.length; + console.log( + ` parse input/append: mean ${pi.mean.toFixed(0)} | p95 ${pi.p95} | max ${pi.max} of ${finalSource.length} final chars (max/full = ${(pi.max / finalSource.length).toFixed(3)})`, + ); + console.log( + ` engine calls: ${out.parseInputs.length}/${effectiveAppends} appends (${fastPathAppends} construct-free appends skipped the engine); finalize parsed ${out.finalizeInput} chars once`, + ); + // `settledUntil` cannot show this: finalize settles everything. + if (pi.max / finalSource.length > 0.9) { + console.log( + ` NEVER ANCHORED: the largest append re-read ${((pi.max / finalSource.length) * 100).toFixed(1)}% of the ` + + `final document and the average one ${((pi.mean / finalSource.length) * 100).toFixed(1)}%. ` + + 'A list (or an unclosed fence, or one giant paragraph) offers the session no safe anchor.', + ); + } + } console.log( - ` parse input/append: mean ${pi.mean.toFixed(0)} | p95 ${pi.p95} | max ${pi.max} of ${finalSource.length} final chars (max/full = ${(pi.max / finalSource.length).toFixed(3)})`, + ` incremental-vs-full reparse ratio: ${reparseRatio.toFixed(3)} ` + + '(lower is better; 1.0 = no cheaper than a full reparse per chunk)', ); console.log( - ` engine calls: ${out.parseInputs.length}/${effectiveAppends} appends (${fastPathAppends} construct-free appends skipped the engine); finalize parsed ${out.finalizeInput} chars once`, + ` streamed ${fmtMs(streamed.p50)} = median of ${streamed.n} replay(s), each the SUM of its ` + + `${deltas.length} append times`, + ); + console.log( + ` naive ${fmtMs(naive.p50)} = median of ${naive.n} replay(s), each the SUM of ` + + `${deltas.length} full reparses of the ${finalSource.length}-char final document`, + ); + if (streamed.n > 1 || naive.n > 1) { + console.log( + ` spread streamed ${fmtMs(streamed.min)}–${fmtMs(streamed.max)}, ` + + `naive ${fmtMs(naive.min)}–${fmtMs(naive.max)} ` + + `(ratio ${(streamed.min / naive.max).toFixed(3)}–${(streamed.max / naive.min).toFixed(3)} at the extremes)`, + ); + } + if (out.finalSnapshot) { + const s = out.finalSnapshot; + console.log( + ` final: phase=${s.phase}, blocks=${s.document.blocks.length}, settledUntil=${s.settledUntil}/${s.document.source.length}`, + ); + } + + // `ok`/`OVER` padding matches bench/pathological.mjs; workflow logs grep both. + if (!gating) return; + + const gate = (label, ms, budget) => { + if (budget === undefined) { + console.log(` ${'off'.padEnd(5)} ${label.padEnd(10)} ${fmtMs(ms)} (no budget passed for this one)`); + return; + } + const over = ms > budget; + if (over) anyOver = true; + console.log(` ${(over ? 'OVER' : 'ok').padEnd(5)} ${label.padEnd(10)} ${fmtMs(ms)} vs ${budget} ms`); + }; + + if (out.chunkTimes.length === 0 || out.replayTotals.length === 0) { + anyVacuous = true; + console.log( + ` ${'none'.padEnd(5)} ${'gate'.padEnd(10)} nothing was timed — ` + + `\`--max-chunks ${maxChunks}\` left ${deltas.length} chunk(s) and \`--repeat ${repeat}\` ` + + `${out.replayTotals.length} recorded replay(s)`, + ); + return; + } + + gate('chunk p99', t.p99, chunkBudgetMs); + gate('finalize', out.finalizeMs, finalizeBudgetMs); +} + +if (gating) { + console.log( + `gating each transcript: chunk p99 ${chunkBudgetMs === undefined ? 'off' : `${chunkBudgetMs} ms`}, ` + + `finalize ${finalizeBudgetMs === undefined ? 'off' : `${finalizeBudgetMs} ms`}`, ); } -console.log( - ` incremental-vs-full reparse ratio: ${reparseRatio.toFixed(3)} (streamed ${fmtMs(streamedAppendMs)} vs naive ${deltas.length} × ${fmtMs(fullMs)} = ${fmtMs(naiveMs)}; lower is better)`, -); -if (out.finalSnapshot) { + +for (const [i, transcriptPath] of transcriptPaths.entries()) { + if (i > 0) console.log(''); + runTranscript(transcriptPath); +} + +if (gating && (anyOver || anyVacuous)) { + console.log(''); console.log( - ` final: phase=${out.finalSnapshot.phase}, blocks=${out.finalSnapshot.document.blocks.length}, settledUntil=${out.finalSnapshot.settledUntil}/${out.finalSnapshot.document.source.length}`, + anyVacuous && !anyOver + ? 'a replay timed nothing — a gate over nothing is a failure.' + : 'budget exceeded — failing.', ); + process.exit(1); } diff --git a/bench/support.mjs b/bench/support.mjs index fe7afa9..83d2cac 100644 --- a/bench/support.mjs +++ b/bench/support.mjs @@ -68,6 +68,9 @@ export function loadLibrary() { ...require(path.join(dist, 'engine', 'native', 'index.js')), ...require(path.join(dist, 'stream', 'StreamSession.js')), ...require(path.join(dist, 'document', 'visit.js')), + ...require(path.join(dist, 'stream', 'repair.js')), + ...require(path.join(dist, 'selection', 'runs.js')), + ...require(path.join(dist, 'selection', 'mapSelection.js')), }; } @@ -193,6 +196,22 @@ export async function resolveEngine(lib, label) { return { name: 'native', engine: native.engine, parse: native.parse, addonPath: native.addonPath }; } +/** + * Exits 0 after a failed precondition, or 1 under `--require-engine`, which the + * workflows pass so a gate cannot go green having measured nothing. + */ +export function exitWithoutEngine(label, what = 'the native engine did not resolve') { + if (hasFlag('require-engine')) { + console.error( + `${label} --require-engine was passed and ${what}, so this run measured nothing. ` + + 'Failing rather than reporting a gate that passed over zero samples.', + ); + process.exit(1); + } + console.log(`${label} exiting 0 (pass --require-engine to make this a failure).`); + process.exit(0); +} + /** * Times `run()` `iterations` times after `warmup` untimed calls. * diff --git a/bench/throughput.mjs b/bench/throughput.mjs index 61223bf..d4fad91 100644 --- a/bench/throughput.mjs +++ b/bench/throughput.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node -// Cold-parse throughput: parse a concatenated markdown corpus N times and -// report MB/s. Corpus = every CommonMark spec example's markdown plus the -// conformance fixtures, replicated to a workload-sized document. +// Warm full-document parse throughput in MB/s over every CommonMark spec example +// plus the conformance fixtures, replicated to a workload-sized document. +// +// Warm on purpose: bench:pathological is the cold one, and the two differ ~7x. // // Usage: node bench/throughput.mjs [--quick] [--iterations N] [--replicas R] // @@ -53,7 +54,7 @@ const s = stats(samples); const mbPerSecMean = bytes / 1e6 / (s.mean / 1000); const mbPerSecBest = bytes / 1e6 / (s.min / 1000); -console.log('parse throughput (preset: llmChat)'); +console.log('parse throughput, warm (preset: llmChat)'); console.log(` corpus: ${fmtBytes(bytes)} (${corpus.length} UTF-16 units)`); console.log(` iterations: ${iterations} (+${warmup} warmup)${quick ? ' [quick]' : ''}`); console.log( diff --git a/conformance/fixtures/transcript-giant-list.json b/conformance/fixtures/transcript-giant-list.json new file mode 100644 index 0000000..483d6aa --- /dev/null +++ b/conformance/fixtures/transcript-giant-list.json @@ -0,0 +1,2490 @@ +{ + "name": "giant-bullet-list", + "description": "Deterministic replay transcript: a 420-item single bullet list — the commonest long LLM answer shape, and one that never anchors, because a blank line does not end a list. Chunked into LLM-ish deltas (0-18 UTF-16 units, BMP-only so every cut is surrogate-safe). Pinned so bench/streaming-replay.mjs reports the never-anchoring case next to the anchoring one.", + "deltas": [ + "## Chec", + "klist for the m", + "o", + "bile per", + "f", + "ormance pass\n\n- Ca", + "che **toke", + "n r", + "efresh** with", + "out a ", + "re-render (", + "", + "#1", + ")", + "\n- Trim **cold st", + "art** i", + "n t", + "he re", + "", + "le", + "as", + "e build (#2)\n-", + " Pin r", + "etry", + " backoff on th", + "e JS thread onl", + "y (#3)", + "\n- Debounce **lay", + "", + "out th", + "rash*", + "* behind a featu", + "re flag (#4)\n- De", + "fer push token", + "s in t", + "he release buil", + "d (#5)\n-", + " Batch tel", + "emetr", + "y", + " sampling per navi", + "gatio", + "n entry ", + "(", + "#6)\n- ", + "Audit schema m", + "igration once ", + "per session", + " (#7)\n- Pin ", + "lay", + "out thrash an", + "d log the delta (", + "#8)\n- Trim of", + "fline queue un", + "d", + "e", + "r ", + "a memory c", + "ap ", + "(", + "#9)\n- Pre", + "fetch `dark mod", + "e", + " tokens", + "` without a re-", + "rend", + "er (#10)\n- D", + "e", + "bounce `schema m", + "igration` on the ", + "JS thread on", + "ly (#11)\n- Def", + "er `cl", + "ipboard po", + "licy` without a", + " re-ren", + "der (#1", + "2)\n-", + " Batch cold st", + "", + "art with a 200 m", + "", + "s budget (#", + "13)", + "\n- C", + "ache `cli", + "pboard policy` ", + "", + "i", + "n the releas", + "e build (", + "#14)\n- Cache ", + "layout thrash per", + " navigation entry", + " (#15", + ")\n- Cache bun", + "dl", + "e size on the JS", + " th", + "read", + " only (#16)\n- Bat", + "ch image", + " c", + "ach", + "e pe", + "r na", + "vigation entry", + " (#17)\n- Measure", + " push token", + "s b", + "efore the first f", + "rame (#18)\n- Debo", + "u", + "nce c", + "rash repo", + "rting", + " before the f", + "irst frame (", + "#1", + "9)\n- Trim **push ", + "tokens** behind a", + " featur", + "e flag (#20)\n", + "- Prefetch", + " **text", + " selection** pe", + "r nav", + "igation entr", + "y (#21)\n- Debou", + "nce accessibi", + "", + "lity labels unde", + "r a memory cap (#2", + "2", + ")\n- Measure *", + "*cold start** and", + " log ", + "the ", + "d", + "e", + "lt", + "a (#23)\n- Batch", + " push", + " tokens wi", + "tho", + "ut a", + "", + " re-ren", + "der (#24)\n- ", + "Batch ", + "**deep links** wit", + "h a 2", + "00 ms ", + "bud", + "get", + " (#25)\n-", + " Measure offline", + " queue withou", + "t a re-render (#", + "26)\n- ", + "Pin tele", + "metry sampling w", + "ith", + " ", + "a 20", + "0 m", + "s bud", + "get", + " (#27)", + "\n- Audit `push tok", + "ens` per navigatio", + "n entry (#28)\n- ", + "Pin co", + "ld start under a m", + "emo", + "ry ", + "cap (#29)\n- C", + "ache `bundl", + "e size` ", + "without ", + "a r", + "e-ren", + "der (#30)\n- ", + "A", + "udit `retry ", + "backof", + "f` witho", + "u", + "t a r", + "e-render (#31)\n- I", + "nline ", + "**retry backoff*", + "* before the fi", + "rst fr", + "a", + "me (#32)\n- P", + "refet", + "ch", + " `deep links` ", + "in the", + " release build", + " (#33)\n-", + " Inline dark", + " mode t", + "okens b", + "ehind a feature f", + "lag ", + "(#34)\n- Pref", + "etch toke", + "n refre", + "sh on the JS ", + "thread on", + "ly (#35)\n-", + " Cache ", + "gesture c", + "on", + "fli", + "cts without a r", + "", + "e-render (#", + "36", + ")\n- Debounce push ", + "tokens without ", + "a re-rend", + "er ", + "(#37)\n- M", + "easure gestu", + "re con", + "flicts u", + "nder ", + "a m", + "emory ca", + "p (#38)\n- ", + "Audit *", + "*layout th", + "ra", + "sh** wit", + "", + "h a 200 m", + "s budget (", + "#39)\n- Pin ", + "", + "retry", + " backo", + "f", + "f on the JS thread", + " only ", + "(#40)", + "\n- Prefetc", + "h pus", + "h tokens and ", + "log the", + " delta (", + "#41)\n- Pre", + "fe", + "t", + "ch crash report", + "in", + "g on the JS t", + "hrea", + "d ", + "only (#", + "42)\n- Defer im", + "age cache w", + "ithou", + "t a re-", + "r", + "ender (#43", + ")\n- A", + "udit retry ", + "b", + "ackoff under a mem", + "ory cap (#44)\n- In", + "line accessi", + "bi", + "lity", + " la", + "be", + "l", + "s", + " before the ", + "first f", + "rame", + " (#45)\n- Cache sch", + "ema migration ", + "and log t", + "he delt", + "a (#46)", + "\n", + "- Cache", + " locale fall", + "bac", + "ks in the", + " release ", + "build (#47)\n- Inl", + "ine **l", + "ay", + "out thrash** wi", + "thout a re-", + "render (#4", + "8)\n- Audit ima", + "ge cache on the", + " JS th", + "read only (#49)\n- ", + "Meas", + "ure **crash repor", + "ting** an", + "d log the d", + "elta (#50)\n", + "- Meas", + "ure locale fallbac", + "ks in the release", + " ", + "build (#51)", + "\n- Cache bu", + "n", + "dle ", + "size under ", + "a memory ca", + "p ", + "(#52)\n-", + "", + " Audit clipboard", + " poli", + "cy behind ", + "a feature", + " flag (#53)\n- Tr", + "im offli", + "", + "ne que", + "ue without a ", + "re-ren", + "der (#54)\n- Pin ", + "layout", + " ", + "thra", + "s", + "h on th", + "e JS", + " thread ", + "only (#55)\n- Pi", + "n push toke", + "ns and log the ", + "delta", + " (#5", + "6)\n- Inline s", + "chema migration", + " without ", + "a re-", + "ren", + "der (#57)\n- Aud", + "it retry backo", + "ff before the fi", + "rst fra", + "me ", + "(#58)\n- Debounce ", + "crash re", + "porting per navi", + "gation entry (#59", + ")\n- Deb", + "ounce sch", + "ema mig", + "rati", + "on under ", + "a memory cap (#60", + ")\n- Batch pus", + "h token", + "s on the JS thr", + "ead only (#61)\n- ", + "Trim o", + "ffline queue wi", + "thout a ", + "re", + "-re", + "nder (#62)\n- Pin ", + "retry backo", + "ff", + " bef", + "ore the first f", + "rame (", + "#63)\n- Trim **", + "cold start** ", + "once per session ", + "(#64)", + "\n- Defer r", + "etry ", + "backoff witho", + "ut a re-", + "rend", + "er (#65)\n- Prefetc", + "h clipboard polic", + "y and log th", + "e ", + "", + "delta (#66)\n- Pr", + "efetch f", + "ont loading onc", + "e per sess", + "ion (#67)\n- ", + "Measure layout ", + "thrash in ", + "the release ", + "buil", + "d (#", + "68)\n- Inline ", + "dark mod", + "e tokens o", + "n the", + " JS thread onl", + "y (#69)\n-", + " Inline **te", + "lemetry sampling", + "** per n", + "a", + "vigation ent", + "ry (#70)\n", + "- Batc", + "h schema migration", + " behind a featu", + "re flag (#71)\n-", + " Pin imag", + "e cache", + " b", + "efore the firs", + "t fra", + "me (", + "#72)\n- Batch t", + "oken refresh w", + "ith a 200 ms budg", + "et (#73)", + "\n- I", + "nline c", + "lipb", + "oard po", + "licy with a 200 ms", + " budget (#74)\n- ", + "Defer *", + "*layout thrash", + "** per", + " na", + "vi", + "gation", + " entry (#75)\n", + "- Batch `te", + "lemetry samp", + "ling` in the r", + "elease build (#", + "7", + "6)\n- Inlin", + "e p", + "us", + "h tokens wi", + "th a 200 m", + "", + "s budget (#77)\n", + "- Measure offline", + " ", + "q", + "ueue u", + "nder ", + "", + "a memory ca", + "p (#78)\n- Deboun", + "ce push tok", + "ens before the f", + "irst f", + "rame ", + "", + "(#79)\n- Defe", + "r **crash repo", + "rting** u", + "nder a", + " memory", + " cap (#80)", + "", + "\n- D", + "eboun", + "ce **schema mi", + "gration** once pe", + "r", + " session (#81)\n- ", + "Prefetch **accessi", + "bili", + "ty labels** in th", + "e release build (#", + "82)", + "", + "\n-", + " Cach", + "e cl", + "ipboard polic", + "y on the JS thr", + "ead o", + "nl", + "y (#83)\n- Deb", + "ou", + "nce layout thras", + "h", + " be", + "hind a featur", + "e f", + "lag (#84)\n- Prefe", + "", + "tch list ", + "", + "virtua", + "lization wit", + "h", + " ", + "a 20", + "0 ms", + " budget (#85", + ")\n- Trim retry", + " backoff once per ", + "session (#86)\n- P", + "refetch gesture co", + "nflicts before the", + " first frame (#87)", + "\n- Inli", + "ne access", + "ibility labels ", + "without a re", + "-render", + " (#8", + "8)\n- Measure fon", + "t loadin", + "g p", + "er navigation", + " e", + "ntry (#8", + "", + "9)\n- T", + "rim clipboard ", + "pol", + "icy u", + "n", + "der a memo", + "ry cap (#90)\n- D", + "e", + "fer clipboard", + " pol", + "icy before the", + " first ", + "", + "frame", + " (#9", + "1)\n- Audit lay", + "out thrash o", + "n the JS thread o", + "", + "nly (#92)\n-", + " Debounce ", + "font loa", + "ding in the r", + "elease build (#9", + "3)\n- Cach", + "e offline queue b", + "", + "efore ", + "the", + " f", + "irst fra", + "me (", + "#94)\n-", + " Trim i", + "mage ", + "cache o", + "nce per sess", + "ion (", + "#95)\n- Inli", + "ne `deep links` ", + "beh", + "ind a feat", + "ure flag", + " (#", + "96)\n- Measure d", + "eep links withou", + "t a", + " re-render (#97)", + "\n- Inlin", + "e **toke", + "n refresh** befo", + "re the f", + "irst frame (", + "#98)\n- Prefetch `", + "deep links` once ", + "per session (#9", + "", + "9", + ")\n- Trim **re", + "try", + " backoff** and lo", + "g ", + "the de", + "lta (#100)\n- Pr", + "efetch im", + "age cache", + "", + " on the JS threa", + "d only (#101)\n", + "- Batc", + "h clipbo", + "ard policy with", + " a 200 ", + "ms budget (#", + "10", + "2)\n- Pref", + "etch fo", + "nt loading ", + "bef", + "ore the", + " first fram", + "e (#103)\n", + "- Defer **image", + " cac", + "he** per ", + "navigation entry ", + "(", + "#104)\n- Def", + "er ", + "pus", + "h tokens b", + "ehin", + "d a", + " feature flag ", + "(#105)\n- Trim", + " ", + "token ref", + "", + "resh with a 200 ms", + " bud", + "ge", + "t (#106)\n- Deboun", + "ce ges", + "ture conflicts und", + "er ", + "a memory c", + "ap ", + "(#107)", + "\n- Debounce `l", + "ist virtualizati", + "on` bef", + "ore", + " the fi", + "rst frame (#1", + "08", + ")\n- ", + "Me", + "asure ", + "push ", + "tokens ", + "wit", + "h a 20", + "0", + "", + " ms b", + "udget (", + "#109)\n- B", + "atch ", + "**telemetry s", + "ampling**", + " under ", + "a memory cap (#", + "110)", + "\n- Defer *", + "*deep l", + "inks** with", + " a 200 m", + "s budget (#111)\n-", + " Pin **l", + "ocale fallbacks", + "** in the relea", + "se build", + " (#112)", + "\n- Pi", + "n imag", + "e cache w", + "ith a 200 ms budge", + "t (#113)\n- Prefe", + "tch retry backoff", + " ", + "on the JS ", + "thread only (#1", + "14)\n- Cache telem", + "", + "et", + "ry sampling in", + " the release ", + "build (#115)\n- ", + "Pin dar", + "k mode tokens with", + " a 200", + " ms", + " budget (#116)\n", + "- ", + "Batch telemetry", + " sampling once p", + "er session (#117)", + "\n- Measure `", + "offline queue` per", + " navigation en", + "try (#118)\n-", + " Measu", + "re `locale fal", + "lbacks`", + " under a m", + "emory cap", + " (#119)\n- Debou", + "nce layout t", + "hrash behind a f", + "eature f", + "lag (#120", + ")\n- ", + "Batch font loadi", + "ng", + " without a re-re", + "nder ", + "(#121", + ")\n- Defer *", + "*dark mode toke", + "ns** behind a f", + "eature flag ", + "(#122)", + "\n- Cache ", + "**image cache** ", + "once per sessi", + "on (#123)\n- Me", + "asure **offlin", + "e queue** and", + " log the del", + "ta (#124)\n-", + " Aud", + "it offline qu", + "eue bef", + "ore the first ", + "frame (#125)\n", + "", + "- Pre", + "fet", + "c", + "h `image ca", + "che` in", + " the release buil", + "d (#126)\n- Pre", + "fetch list virt", + "ualization on", + " the JS t", + "hread only ", + "(#127)\n- ", + "Defer l", + "ist virtualization", + " ", + "u", + "nder a memory ", + "cap (#128)\n- P", + "refetc", + "h layout thrash ", + "wi", + "thout a re", + "-render (#129)", + "\n- Batch **font l", + "oad", + "ing**", + " once ", + "per", + " sessi", + "on (#13", + "0)\n-", + " Pin clipboa", + "rd poli", + "cy in the re", + "lease b", + "uild (#131)\n- Cac", + "he token ", + "refr", + "esh behi", + "nd a f", + "eature", + " flag (#132)", + "\n", + "- P", + "refetc", + "h **list ", + "virtualizati", + "", + "on", + "** behin", + "d a fea", + "tur", + "e flag", + " (#13", + "3", + ")\n-", + " Debounce **gestu", + "re confl", + "ict", + "s*", + "* behind a f", + "eature fl", + "ag ", + "(#134", + ")\n- Cache **font", + " loading** onc", + "e", + " per session (#135", + ")\n- Batch `col", + "d sta", + "rt", + "` withou", + "t a re-", + "render (#136", + ")\n- Cache **col", + "d start** per ", + "navigation en", + "tr", + "y (#13", + "7)", + "\n- Measure", + " push tok", + "ens un", + "", + "der a mem", + "ory cap ", + "(#138)\n- Inline ", + "`dark mode toke", + "n", + "s` with ", + "a", + " 200 ms budget (", + "#139)\n- Debounce ", + "crash reporti", + "ng without ", + "a re-r", + "e", + "nder (#140)\n- Tri", + "m dark mode to", + "kens on", + " the JS th", + "", + "read onl", + "y (#141", + ")\n-", + " Cac", + "he font", + " loadin", + "g per navigation", + " entry (#", + "142)\n- Trim p", + "ush tokens ", + "", + "in the release bu", + "ild (#143)\n- Defe", + "r accessibil", + "ity", + " labels and ", + "log the d", + "el", + "ta (#144)\n- Inline", + " **cold start** ", + "before the firs", + "", + "t fram", + "e (#145", + ")\n- Audit cra", + "sh reporting bef", + "ore the first fra", + "me (#146)\n- Audit", + " text select", + "ion ", + "under a memor", + "y cap (#147)\n", + "-", + " Trim **schema", + " migration** befo", + "re", + " the first", + "", + " frame ", + "(#148)\n- T", + "rim **image c", + "ache** per na", + "vigation entry ", + "(#149", + ")\n- Inline ac", + "c", + "essibility label", + "s beh", + "in", + "d a ", + "featu", + "re", + " flag (#150)\n", + "- T", + "rim teleme", + "try s", + "ampling and log t", + "he delta", + " (#151)\n- Pin d", + "ark mode ", + "tokens behind a fe", + "ature flag (#152)\n", + "- Measure **font", + " loading** on ", + "the ", + "JS thread o", + "nly", + " (#1", + "53)\n- Def", + "er ", + "image cache o", + "n th", + "e JS th", + "read onl", + "y", + " (#", + "154)\n- ", + "Audit dark ", + "mode t", + "okens witho", + "ut a re-render", + " (#15", + "5)\n- Measu", + "re acce", + "ssibil", + "i", + "ty labels ", + "once per sessio", + "n (#156)", + "\n- Debounce acce", + "", + "", + "ssibility la", + "bels with a 200 ", + "ms", + " budget (#157)\n- D", + "eboun", + "ce ", + "bundle size ", + "in the relea", + "se build (#158)", + "\n- Defer", + " gesture c", + "", + "onflicts", + " un", + "der a m", + "emory cap (#159)", + "\n- ", + "Pin token ref", + "resh b", + "ehind", + " a feature fla", + "g (", + "#160)\n- Defer ", + "font lo", + "adi", + "ng with a 200 ms", + " bud", + "get (#161)\n-", + " Prefetch **fon", + "t", + " loading** w", + "ith a ", + "200 ms ", + "budge", + "t (#162", + ")\n", + "- Trim", + " layout thr", + "ash and l", + "og ", + "the de", + "lta (#163)\n- Measu", + "re bundle si", + "ze with a 2", + "00 ms ", + "budget (#16", + "4)\n- Defer `", + "crash reportin", + "g", + "` and log th", + "e delta ", + "(", + "#165)\n- Pin `gestu", + "re ", + "conflicts` with", + "out", + " a re-rend", + "er (#1", + "66)\n- Trim clipb", + "oard policy once ", + "per", + " ", + "session (#16", + "7)\n- Pre", + "fe", + "tch offlin", + "e queue in the rel", + "ease", + " build (#168)\n- ", + "Measure deep l", + "inks in ", + "the rele", + "ase buil", + "d (#1", + "69)\n-", + " Batch **lay", + "out thrash**", + " ", + "per navigation ", + "entry (#17", + "0)\n-", + " Meas", + "", + "ure **l", + "ayout thrash** an", + "d log t", + "he delta (#1", + "7", + "1)\n- Trim offline", + "", + " queue p", + "er navigation", + " ", + "entry (#17", + "2)\n- Cache **la", + "", + "yout thras", + "h** before the ", + "first frame (", + "#173)\n- Batch **a", + "ccess", + "ib", + "ility labe", + "ls** before the f", + "irs", + "t f", + "ram", + "e (#174)\n- Trim", + " dark ", + "mode tokens per na", + "", + "vigation ", + "ent", + "ry (#175)\n- ", + "In", + "line `font loading", + "` once p", + "er session", + " (#1", + "76)\n- Me", + "a", + "sure `sc", + "hema migration` wi", + "thout a re-ren", + "der (#", + "177)\n-", + " Audit **", + "dark mode t", + "okens", + "*", + "* u", + "nder a memory c", + "ap (#178)\n- Pin ", + "token ref", + "resh", + " with", + " a 20", + "0 ms budge", + "t (#179)", + "\n- Measure clipboa", + "rd poli", + "cy on the ", + "JS thread only (", + "#180)\n- De", + "bou", + "nce ge", + "sture confl", + "icts b", + "efore the firs", + "t fr", + "ame ", + "(#181", + ")\n- Pr", + "efetc", + "", + "h", + "", + " retry ba", + "ckoff un", + "der a m", + "emory ca", + "p (#", + "182)\n- Trim ", + "**clipbo", + "ard policy** b", + "ef", + "ore the", + " first frame (#183", + ")\n- Batch d", + "ark mode token", + "s ", + "wit", + "hout a re", + "-re", + "nder (#184)\n- T", + "rim **image cache*", + "* in", + " the", + " release ", + "build (#", + "185)", + "\n- Inline f", + "ont load", + "in", + "g ", + "once p", + "er session (#186)", + "\n- Batch co", + "ld star", + "t per navigation e", + "ntry (#187)\n- Pr", + "efetc", + "", + "h", + " `text sel", + "ection` on", + "ce p", + "er session (#188)", + "\n- Bat", + "ch d", + "eep links befor", + "e t", + "he first fra", + "me (#189)\n", + "- Audit `cold s", + "tart` be", + "hind a feature", + " flag (#190)\n- ", + "Trim", + " list virtual", + "izatio", + "n before the fir", + "st frame (", + "#191", + ")\n- Measure", + "", + " `layout thras", + "h` onc", + "e per ", + "session (#1", + "92)\n- Trim retry", + " back", + "off per navigat", + "ion entry (#19", + "3)\n- Measure text", + " selecti", + "on behin", + "d a feature", + " flag (#194", + ")\n- Batch **i", + "mage ", + "cache** in the ", + "rele", + "ase buil", + "d (#", + "195)\n-", + " Debounce `layout", + " thr", + "ash", + "` per naviga", + "tion entry (#19", + "6)\n- Au", + "dit `offline queu", + "e` with", + " a 200 m", + "s", + " budget (#197", + ")\n- Measure ", + "`schema m", + "igration` on", + " the JS thread ", + "onl", + "y ", + "(#198)\n- Trim `i", + "mage cache` on", + " the JS thread ", + "only ", + "(#199)\n", + "- Pin image ", + "cache o", + "nce per session (", + "#", + "200)\n- Defer", + " text selectio", + "n without a re-", + "render (#201)", + "\n- ", + "Cac", + "he gest", + "ure co", + "nflicts on ", + "the JS thread onl", + "y (#202)\n", + "- Prefetc", + "h gestur", + "e confli", + "", + "cts on t", + "he JS th", + "read only ", + "(#20", + "3)\n- Audit pus", + "h tok", + "ens and log ", + "the d", + "elta (#2", + "04)\n- Batc", + "h **font l", + "oading** and lo", + "g the delta (#2", + "05)\n-", + " Audit **sche", + "ma migrati", + "on** ", + "once per se", + "s", + "sion (#206)\n-", + " Pin sch", + "ema migratio", + "n on th", + "e JS thre", + "ad only (#", + "207)\n- Pin tel", + "emetry sampling be", + "hind a feature fl", + "ag (#208", + ")\n- Batch pu", + "s", + "h tokens o", + "n the ", + "JS t", + "hre", + "ad only (#20", + "9)\n- P", + "in retry backoff u", + "nder a ", + "memory cap ", + "(#210)\n- Audit ", + "clipboard polic", + "y on the JS thread", + " on", + "ly (#211)\n- C", + "ache offline queue", + " per navigat", + "ion ", + "entry (#212)\n-", + " Cache `token refr", + "e", + "sh` under a memory", + " cap (#213)\n-", + " ", + "", + "Defer **layout t", + "hrash** and", + " log the delta", + " (#21", + "4)\n- A", + "udit dark", + " mo", + "de tokens and l", + "og the delt", + "a (#21", + "5)\n- Audit lis", + "t virtualizat", + "ion on t", + "he J", + "S thr", + "ead only (#", + "216)\n- Cache t", + "oken refre", + "sh on the J", + "S ", + "thread only (", + "#217)", + "\n- Batch tele", + "", + "metry sampling onc", + "e per", + " session (#2", + "18)\n- ", + "Audit bundle siz", + "e before the f", + "irst frame", + " ", + "(#219)\n- Measu", + "r", + "e", + " gesture confli", + "ct", + "s with a", + " 200 ms budget ", + "(#220)\n- Mea", + "sure sch", + "", + "em", + "a migration o", + "nce per sessio", + "n (#221)\n", + "- Audit crash repo", + "rting in the relea", + "se build (#222)\n- ", + "Inline bu", + "ndle size bef", + "ore the ", + "first frame (#223)", + "\n- Debounce image", + " cache wit", + "h a 20", + "0 ms b", + "udget (#224)\n- B", + "atch bundle ", + "size", + " o", + "nce per session", + " (#225)\n-", + " Inline gestur", + "e conflicts wit", + "hout a re-rende", + "r (#22", + "6)\n-", + " Cache telemetr", + "y sam", + "pl", + "ing befor", + "e ", + "the first", + " frame (#227)\n-", + " Measure telemetry", + "", + " sampling wi", + "thout a r", + "e-", + "render (#228)\n- ", + "Defer t", + "ext selection pe", + "r navig", + "ation entry (", + "#229)\n- D", + "ebou", + "nce retry backof", + "f behi", + "nd a feature", + "", + " flag ", + "(#230)\n- Defer ", + "**push tokens**", + " be", + "fore t", + "he fir", + "st frame (#231)", + "\n- Cache push", + " toke", + "ns under a ", + "me", + "mory c", + "ap (#2", + "32)", + "\n- Pin cold ", + "start under a memo", + "r", + "y cap (#233)\n- Bat", + "ch telemetry sa", + "mpling on the ", + "JS thread o", + "nly (#234)", + "\n-", + " Pin **token ref", + "resh** b", + "efor", + "e the first fra", + "me (#23", + "5)\n- Batch **t", + "e", + "leme", + "try samp", + "ling** in the", + " release buil", + "d (#236)\n- Pre", + "fetch crash repo", + "rtin", + "g per ", + "navi", + "gation", + " entry (#237)\n", + "- Inline `off", + "line queue` with", + " a 2", + "00 ms budge", + "t (#238)\n- ", + "Defer dee", + "p li", + "nks a", + "n", + "d log the delta", + " (#239", + ")\n- Audit **", + "layout t", + "hrash** ", + "on the JS", + " thread on", + "ly (", + "#240)\n- Defer toke", + "n refresh on the J", + "S", + " thread only (#2", + "41)\n- Prefetch **", + "f", + "ont loadin", + "g**", + " pe", + "r", + " navigation ent", + "ry (#242)", + "\n- Batch **co", + "ld", + " start** pe", + "r navigat", + "ion entry ", + "(#243)\n- ", + "Audit ", + "**sche", + "ma migratio", + "n", + "** and log th", + "e delta ", + "(#24", + "4)\n- D", + "efer offline q", + "ueue before", + " th", + "e", + " ", + "first frame (#", + "245)\n- I", + "nline **gesture c", + "onflicts** before ", + "the f", + "irst frame (#24", + "6", + ")\n- ", + "Cach", + "e dark mode to", + "kens in the", + " release ", + "bu", + "ild (#247", + ")", + "\n- Inline **retr", + "y b", + "ackoff** without a", + " re-r", + "ender (#248", + ")\n- D", + "efer font lo", + "", + "ading un", + "der a memory cap ", + "(#249)\n- Batch dee", + "p links and log th", + "e ", + "delta (#250)", + "\n- P", + "in ", + "sch", + "em", + "a migration once p", + "er ", + "s", + "ession (#251", + ")\n- Audit ", + "locale fallbac", + "ks behind a ", + "feature fla", + "g ", + "(#252)\n- Inlin", + "e **cold ", + "start**", + " once pe", + "r sessio", + "n", + " (#253)\n- ", + "Batc", + "h", + " **clipboa", + "rd policy** pe", + "r n", + "a", + "vigati", + "on e", + "ntry (#25", + "4)\n- Tri", + "m i", + "mage ", + "cache behind a", + " featur", + "e flag (#25", + "5)\n- Debounce ac", + "c", + "essibility l", + "ab", + "els befor", + "e the first ", + "frame (", + "#256)\n- Pin", + " accessibility", + " lab", + "els behind a f", + "eatu", + "re flag (#", + "257)\n- Cache `lo", + "cale fall", + "backs` under", + " a mem", + "ory cap (#258)\n-", + " Deboun", + "ce **schema migr", + "ation** before th", + "e first frame (#25", + "9)\n", + "- ", + "Debounc", + "e **bundle siz", + "e*", + "* with a ", + "200 ", + "ms budget (#260)\n-", + " Audit `bun", + "dle size` ", + "with a", + " 200 ms budget (#", + "261)\n- Mea", + "sure retry ", + "backoff on", + "ce per", + " session (", + "#262)", + "\n- Ba", + "t", + "ch **", + "retry bac", + "koff** on", + "ce per ses", + "sion (#263)\n-", + " Measur", + "e schema migrati", + "on wi", + "t", + "h a 200 m", + "", + "s budget (#264", + ")\n- In", + "line `dar", + "k mod", + "e tokens`", + " und", + "er a ", + "me", + "mory cap (#", + "265)\n- Cache", + " ", + "font loading beh", + "ind a feature flag", + " (#266)\n- Me", + "asure offline", + " queue onc", + "e per ", + "sess", + "ion", + " (#267)\n- Pref", + "etc", + "h deep", + " links under a mem", + "ory cap (#2", + "68)\n- P", + "re", + "fetch font loading", + " wit", + "hout a re-render", + " (#269)\n- Def", + "er `crash ", + "repor", + "ting` once", + " per se", + "ssion (#270)\n-", + " Measure token", + " refresh withou", + "t ", + "a re-rend", + "er (#27", + "1)\n- Cac", + "he layout ", + "thrash and log", + " the delta (#272)", + "\n- Inline **font l", + "oad", + "ing** in", + " the rele", + "ase build ", + "(#27", + "3)\n- Audit `text", + " selection` b", + "efore the fi", + "rst", + " frame (#274)", + "\n- ", + "Pin", + " `list virtual", + "ization`", + " and log the de", + "", + "lta (#275)\n-", + " Defer clipboa", + "rd policy i", + "n the release bui", + "", + "", + "ld (#276)\n- Pref", + "et", + "ch `list virtual", + "ization", + "` before the fir", + "st frame (", + "#277)\n- Bat", + "ch", + " `loc", + "ale fallbacks` ", + "without", + " a ", + "re-render (#278)\n-", + " Cache token refre", + "sh behind a", + " feat", + "ure flag ", + "(#279)\n-", + " Debounce list vir", + "tualiza", + "t", + "ion on ", + "the ", + "JS thread only (#2", + "80)\n- Defer layout", + " thrash u", + "nder a memory cap", + " (#281)\n- Me", + "asure retry back", + "", + "off befor", + "e the first frame ", + "(#282)\n- M", + "e", + "asur", + "e token ref", + "r", + "esh per navigatio", + "n", + " ", + "", + "entry (#283)\n-", + " Inline schema m", + "", + "igration behi", + "nd ", + "a feature flag", + " (#284)\n- Meas", + "ure `dark mode tok", + "ens` p", + "er n", + "avi", + "gation en", + "t", + "ry (#285)\n- B", + "atch re", + "try backoff", + " be", + "fore the first fr", + "ame ", + "(#286)\n- Pin ret", + "ry back", + "off bef", + "ore the", + " first f", + "rame (#28", + "7)\n- Defer imag", + "e cache per navi", + "gation en", + "t", + "ry (#288)\n- Audit", + " telemetry samplin", + "g with a", + " ", + "200 ms budget (#2", + "89)\n- Inlin", + "e `acces", + "sibility ", + "labels`", + " under a", + " memory cap (#2", + "90)\n- Batc", + "h text selec", + "tion in the rel", + "ea", + "se", + " buil", + "d (#291", + ")\n- Trim telemet", + "ry sampling i", + "n th", + "e release ", + "", + "build (#292)\n- D", + "efe", + "", + "r **", + "push ", + "tokens** without ", + "a re-rend", + "er (", + "#", + "2", + "93)\n- Pin `retry", + " backoff` ", + "under a memory ", + "cap (#294", + ")", + "\n- Trim acces", + "sibilit", + "y ", + "labe", + "ls an", + "d log the", + " delta (#295)\n- M", + "easure deep links ", + "before the ", + "fir", + "st frame", + " (#296)\n- M", + "", + "easure *", + "*token refresh", + "** once per ses", + "sion", + " ", + "(", + "#297", + ")\n-", + " Debo", + "unce offline queu", + "e withou", + "t a", + " re-render", + " (#2", + "98)\n-", + " Batch crash", + " reporting and lo", + "g the de", + "lta (#", + "299)\n- Deboun", + "ce loc", + "ale fallba", + "cks", + " ", + "w", + "i", + "th a 200 ms bud", + "", + "get (#300", + ")\n- Audit", + "", + " image cache und", + "er", + "", + " a memory ca", + "p (#301)\n- Defe", + "", + "r layout thrash wi", + "th a 200 ms budget", + " (#302)\n", + "- Deboun", + "ce **font loa", + "din", + "g", + "** in th", + "e r", + "e", + "lease bu", + "ild (#303)\n", + "- Trim **bund", + "le si", + "ze** once p", + "er se", + "ssio", + "", + "n (#304)\n- T", + "rim token", + " refresh before th", + "e first frame (#3", + "05)\n- Prefet", + "ch schema migrat", + "ion", + " behind a feature", + " flag ", + "(#", + "306)\n- Pref", + "etch dark ", + "mode tokens withou", + "t a re-render ", + "(#307", + ")", + "\n", + "- Trim clip", + "", + "board poli", + "cy per navigation", + " entry ", + "(#308)\n- Deb", + "oun", + "ce da", + "rk mode tokens bef", + "ore the first fram", + "e (#309)\n- Audit `", + "gesture confl", + "icts` with", + "out a ", + "re-", + "render (#3", + "10)\n-", + " Measure retry bac", + "koff in the r", + "elea", + "se build (#311", + ")\n- Prefetch font", + " loading ", + "on the JS thread ", + "only (#", + "31", + "2)\n- Bat", + "ch bun", + "dle size ", + "in t", + "he releas", + "e build ", + "(#313)\n- Ba", + "tch te", + "lemetry sa", + "mpling on th", + "e JS t", + "hread only (#314", + ")\n- Audit **schem", + "a ", + "migration** wi", + "th a 200 ms budg", + "et ", + "(#315)\n- Deb", + "ounce font lo", + "ading with", + "out a re-render (", + "#316", + "", + ")\n- Pin **gestu", + "re con", + "flicts** on th", + "e JS thread only", + " (#317)", + "\n- ", + "Trim ret", + "ry bac", + "ko", + "f", + "f pe", + "r navigat", + "ion entry", + " (#318)\n", + "- Inline offli", + "ne queue a", + "nd log", + " the ", + "delta (#319)\n- ", + "Def", + "er `layout thrash", + "` before ", + "the ", + "first frame (#320", + "", + ")\n- Deboun", + "ce gesture con", + "flict", + "s per navig", + "ation e", + "n", + "try", + " (#321)\n-", + " ", + "Audit layou", + "t thrash", + " in the re", + "lease build ", + "", + "(#322)\n- Pin **cra", + "sh rep", + "orting** on the JS", + " ", + "thre", + "ad on", + "ly (#323)\n- Defer ", + "", + "**locale fallb", + "ack", + "s** b", + "efore the", + " ", + "fir", + "st f", + "rame (#324)\n", + "- Tri", + "m", + " bundle si", + "ze before ", + "the f", + "irst frame (#32", + "5", + ")\n- D", + "", + "ebounce `offli", + "n", + "e queue` with", + "out a re-rend", + "er (#326)", + "\n- Audit **cold ", + "start** on t", + "he JS ", + "thread only (#32", + "7)\n-", + " Audit token refr", + "es", + "h once per ", + "sessi", + "on (#328)\n- Audit ", + "`gesture conflic", + "ts` ", + "once p", + "er session (#32", + "9)\n- Trim **cold ", + "start**", + " in the release b", + "uil", + "d (#330)\n- Pin b", + "undl", + "e si", + "z", + "e per navigation e", + "ntry (", + "#331)\n- Mea", + "sure `a", + "c", + "cessibility l", + "abels` once", + " per", + " se", + "ssion (#332)\n", + "- Measu", + "re cli", + "pboard policy", + " under a memory ca", + "p (#333)\n- Audi", + "t bundle si", + "ze with", + "out a re-render ", + "(#", + "334)\n- Mea", + "s", + "ure", + " gesture co", + "nf", + "licts once p", + "er session ", + "(#335)", + "\n- Batch push tok", + "ens behind a f", + "", + "eature flag (#336", + ")\n- Cache `bundl", + "e size`", + " under a m", + "emory c", + "ap (#337)\n- T", + "rim **local", + "e fa", + "llback", + "s** per na", + "vigation entry ", + "(#338)\n- ", + "Debounce `pus", + "h ", + "tokens` per nav", + "ig", + "ation entry", + " (#339)\n- Mea", + "sure clipbo", + "", + "a", + "rd policy on the J", + "S thread onl", + "y ", + "(#3", + "40)\n- Inline gest", + "", + "ure ", + "conflicts with ", + "a 200 m", + "s budget (#341)", + "\n- Prefetch layout", + " t", + "hrash under a memo", + "r", + "y cap (#342)\n- ", + "P", + "refetch **da", + "rk mod", + "e tok", + "ens** wi", + "th", + " a 200 ms budget (", + "#343)\n- Def", + "er acc", + "essi", + "bility labels in ", + "the release b", + "u", + "ild ", + "(#344)\n", + "- Inline", + " cold start wit", + "hout a re-r", + "ender (#345", + ")\n-", + " Defer retry backo", + "ff per navigation", + " entr", + "y (#346)\n", + "- Cache ges", + "ture conf", + "licts and log ", + "the delta (#347", + ")\n- Defer clip", + "board p", + "olicy on ", + "the JS thread", + " only (#3", + "48)\n- Prefetch ", + "", + "accessib", + "ility labels wi", + "th", + "out a re-ren", + "der (#349)\n- Mea", + "sure", + " **text selecti", + "on", + "** p", + "er navigati", + "on e", + "ntry (#350)", + "\n- Bat", + "ch **offline qu", + "eue** with a 2", + "00 ms budget (#3", + "51)\n-", + " Measure tok", + "en refresh behind ", + "a feature flag ", + "(#", + "352)\n- Cache **c", + "", + "li", + "pboard p", + "o", + "licy** ", + "and log", + " the delta (#35", + "3)\n- Batch pu", + "sh tokens", + " behind a fe", + "a", + "", + "ture flag (#35", + "4)\n- Pin **gest", + "", + "ure conflicts", + "** in the rel", + "ease bui", + "ld (#3", + "55)\n-", + " Cache local", + "e ", + "fallbacks u", + "", + "nder a memory", + "", + " cap (#35", + "6)\n- Inline `cold ", + "start` on th", + "e JS", + " ", + "thread only (#3", + "57)\n- Audit text", + " sele", + "ction with", + " a 200 ms budget (", + "", + "#358)\n- T", + "", + "r", + "im ", + "schema mi", + "gration und", + "er a memo", + "ry c", + "ap (#359)", + "\n- Audit cr", + "ash reporting", + " w", + "itho", + "ut ", + "a re-render (#", + "360)\n- Aud", + "it **crash", + " reporti", + "ng**", + " on the JS t", + "hr", + "ead only (#361)\n", + "- Measu", + "", + "re `p", + "us", + "h", + " tokens` without a", + " re-render", + " (#362)\n- Batch", + " accessibili", + "ty la", + "bels before the fi", + "", + "rst frame (#363)\n-", + " Pre", + "fetch **telemet", + "r", + "y sampling** pe", + "r navigati", + "", + "on entry (", + "#364)\n- Trim cold", + " st", + "art without a r", + "e-render (#365)\n-", + " Pr", + "efetch push to", + "kens on the ", + "JS thread only", + " (#366)\n-", + " Pin `telemet", + "ry ", + "sampling`", + " once per sess", + "ion (#", + "367)", + "\n- Cache", + " `retry backoff", + "` behind", + " a f", + "eature flag (#368", + ")\n- Batch `", + "cold start` once p", + "er sess", + "ion (#369)", + "\n- I", + "n", + "line pu", + "sh tokens on t", + "he JS thread ", + "only (", + "#370)\n-", + " Pin schem", + "a m", + "igra", + "tion before the fi", + "rst frame (#371)\n", + "- Batch la", + "yout thrash wit", + "h ", + "a 2", + "", + "00 ", + "ms", + " budget (#3", + "72)\n- Pin **col", + "d star", + "t** in the relea", + "se build ", + "(#373)\n- Pin ", + "layout thrash with", + "out a re-rende", + "r (#374)", + "\n- Defer `crash r", + "eporting` without", + " a re-ren", + "der ", + "(#375)\n- Defer ", + "list", + " virtua", + "lization b", + "ehind a fe", + "ature f", + "lag (#376)\n- Ca", + "che image cache ", + "under a mem", + "ory ca", + "p (#377)", + "\n- Prefetch offl", + "ine queue w", + "ithout a re-rend", + "e", + "r (#", + "378)\n- Prefetch ", + "bu", + "ndle size per navi", + "gatio", + "n entry (#379)\n- ", + "P", + "refetch telemetry ", + "s", + "ampl", + "ing without a re", + "-render (#", + "380)\n- Prefe", + "tch retry b", + "a", + "cko", + "ff once", + " per session (#", + "381)\n- Me", + "a", + "sur", + "e clipboard ", + "policy and ", + "log the de", + "lta (#382)\n- ", + "", + "Audit gestur", + "e confli", + "cts", + "", + " per nav", + "igation entry (#38", + "3)\n- Trim o", + "ffline queue per n", + "avigat", + "", + "ion entry (#38", + "4)\n- Audit *", + "*loca", + "le", + " fallba", + "c", + "ks** on the JS ", + "thread ", + "only (#385)\n- ", + "Trim c", + "lipboard", + " policy and", + " log the delta (#", + "386)\n- Me", + "asu", + "re d", + "ark mode tokens ", + "and log the de", + "lta (#387)\n", + "- Defer token ", + "refresh without a", + " re-re", + "nder (#388)", + "\n- Trim *", + "*cold sta", + "rt** in the", + " release build (#", + "389)\n- Measure `", + "local", + "e fallbacks", + "`", + " without a re", + "-rend", + "er (#390)\n- De", + "f", + "er `tok", + "en refres", + "h` on ", + "the JS th", + "read o", + "nly (#391)\n-", + " Measure ", + "gestu", + "re conflicts and", + " l", + "og the", + " ", + "delta (#392)", + "\n- Deb", + "ounce d", + "ark", + " mode token", + "s in the relea", + "se build (", + "#393)\n-", + " Measure gesture", + " conflicts in the", + " rel", + "ease ", + "build (#3", + "94)\n- Prefe", + "t", + "ch list vir", + "tua", + "lization be", + "fo", + "re ", + "the first ", + "frame (#395)\n- D", + "efer layout thrash", + " and log the", + " delt", + "", + "a (#", + "396)\n- Pref", + "etch **font lo", + "ading** on", + " ", + "the JS thread onl", + "y (#397)\n", + "- ", + "B", + "atch `offline ", + "qu", + "e", + "ue` in the releas", + "e", + " build (#", + "398)\n- Pin gestu", + "re", + " conflicts wit", + "h a", + " 200 ms budget (#", + "399)\n", + "- Prefetch bundle", + " size without ", + "a re-render", + " (", + "#400)", + "\n- Debounce ge", + "sture con", + "flicts on the J", + "S th", + "read only (#401", + ")\n- Audit s", + "che", + "ma migration be", + "fore th", + "e", + " firs", + "t frame (#402)", + "\n- Inli", + "ne d", + "eep ", + "links on ", + "the", + " JS", + " t", + "hread", + " on", + "ly (#403)", + "\n- Audit", + " gesture c", + "onflicts ", + "with a 200", + " ms budge", + "t (#404)\n- ", + "Batch cold sta", + "rt per navigat", + "ion entry (#405", + ")\n- Defe", + "r `cold start` wit", + "h a 2", + "00 m", + "s ", + "budget (#406)\n-", + " Me", + "asure", + " c", + "lipboard policy be", + "hind a feat", + "ure flag (#40", + "7)\n- Prefet", + "ch retry backof", + "f in t", + "he release", + " build (#", + "408)\n- Trim crash", + " reporting", + " behind a feature ", + "flag (#409", + ")", + "\n- Cache **", + "gesture conflicts*", + "* per", + " nav", + "igation ent", + "ry (#410)\n- Inline", + " gestu", + "r", + "e conflic", + "ts behind a featu", + "re flag (", + "#", + "411)", + "\n- Inline telemetr", + "y samplin", + "g behind ", + "a feature flag (", + "#412)\n- D", + "ebounce text s", + "electi", + "on on the", + " JS", + " thre", + "ad only ", + "(#413)\n- Inlin", + "e list virtual", + "izatio", + "n per navigation", + " entry (#414)\n-", + " Batc", + "h telemetry sam", + "pling and l", + "og the delta (#415", + ")\n- Measure d", + "ark ", + "mode to", + "kens", + " in the release", + " bui", + "ld (#416", + ")\n- Trim font loa", + "ding on", + " the JS thread o", + "nly (", + "#", + "417)\n- De", + "fer gesture ", + "conflicts in the", + " ", + "release build (#41", + "8)\n- Pref", + "etch", + " crash rep", + "orting a", + "nd log the del", + "ta (#419)", + "\n- Pin offli", + "ne queu", + "e with a 20", + "0 ms bu", + "d", + "get (", + "#420)\n" + ] +} diff --git a/conformance/selection/incremental-projection.test.ts b/conformance/selection/incremental-projection.test.ts new file mode 100644 index 0000000..4f3482c --- /dev/null +++ b/conformance/selection/incremental-projection.test.ts @@ -0,0 +1,250 @@ +/** + * The projector carries merge, mark, and embed-numbering state across block + * boundaries, so an extended projection is checked against a fresh one. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { ParsedDocument } from '../../src/document/nodes'; +import { parseDocument } from '../../src/engine/Engine'; +import { + describeNative, + linkNativeEngineAsDefault, +} from '../../src/engine/native/__tests__/support'; +import { presets } from '../../src/engine/options'; +import type { EngineOptions } from '../../src/engine/options'; +import { projectRun } from '../../src/selection/mapSelection'; +import { segmentRuns } from '../../src/selection/runs'; +import type { EmbedLookup, RunSegment } from '../../src/selection/runs'; +import { StreamSession } from '../../src/stream/StreamSession'; +import { createRunProjectionCache } from '../../src/view/projectionCache'; +import type { RunProjectionCache } from '../../src/view/projectionCache'; +import { runKey } from '../../src/view/runIdentity'; + +const FIXTURE_DIR = path.resolve(__dirname, '..', 'fixtures'); +const SPEC_PATH = path.resolve(__dirname, '..', 'vendor', 'spec.json'); + +interface Case { + readonly label: string; + readonly source: string; +} + +function loadCorpus(): Case[] { + const cases: Case[] = []; + const spec = JSON.parse(fs.readFileSync(SPEC_PATH, 'utf8')) as { + markdown: string; + example: number; + }[]; + for (const entry of spec) { + cases.push({ label: `spec example ${entry.example}`, source: entry.markdown }); + } + for (const file of fs.readdirSync(FIXTURE_DIR).filter((f) => f.endsWith('.md'))) { + cases.push({ + label: `fixture ${file}`, + source: fs.readFileSync(path.join(FIXTURE_DIR, file), 'utf8'), + }); + } + return cases; +} + +const corpus = loadCorpus(); + +linkNativeEngineAsDefault(); + +function prefixRun(run: RunSegment, count: number): RunSegment { + const blocks = run.blocks.slice(0, count); + return { + ...run, + blocks, + span: { + start: blocks[0].span.start, + end: blocks[blocks.length - 1].span.end, + }, + }; +} + +function growAndCompare( + run: RunSegment, + doc: ParsedDocument, + options?: { embed?: EmbedLookup }, +): number { + const cache = createRunProjectionCache(); + for (let count = 1; count <= run.blocks.length; count += 1) { + const grown = prefixRun(run, count); + expect(cache.project(grown, doc, options)).toEqual( + projectRun(grown, doc, options), + ); + } + return run.blocks.length; +} + +describeNative.each([ + ['llmChat', presets.llmChat], + ['everything', presets.everything], +] as [string, EngineOptions][])('incremental projection (%s)', (_name, options) => { + it('grows every corpus run to exactly the full projection', () => { + let steps = 0; + for (const { label, source } of corpus) { + const doc = parseDocument(source, options); + for (const run of segmentRuns(doc)) { + if (run.standalone) { + continue; + } + try { + steps += growAndCompare(run, doc); + } catch (err) { + throw new Error(`${label}: ${(err as Error).message}`); + } + } + } + // Vacuous on one-block runs, so the corpus must supply many multi-block + // ones. + expect(steps).toBeGreaterThan(500); + }); + + it('grows identically when an embed lookup is claiming nodes', () => { + // One inline and one block embed, so block seams are crossed with an embed + // on either side. + const embed: EmbedLookup = (node) => + node.kind === 'link' || node.kind === 'codeBlock' + ? { width: 100, height: 40, text: '[card]' } + : undefined; + let claimed = 0; + for (const { label, source } of corpus) { + const doc = parseDocument(source, options); + for (const run of segmentRuns(doc, { embed })) { + if (run.standalone) { + continue; + } + try { + growAndCompare(run, doc, { embed }); + } catch (err) { + throw new Error(`${label}: ${(err as Error).message}`); + } + claimed += projectRun(run, doc, { embed }).embeds?.length ?? 0; + } + } + expect(claimed).toBeGreaterThan(50); + }); +}); + +/** + * Claims nothing; summing the spans of `topLevel` nodes counts source + * characters projected. + */ +function meter(): { + embed: EmbedLookup; + measure: (body: () => T) => T; + chars: () => number; +} { + let counting = false; + let chars = 0; + return { + embed: (node, context) => { + if (counting && context.topLevel) { + chars += node.span.end - node.span.start; + } + return undefined; + }, + measure: (body) => { + counting = true; + try { + return body(); + } finally { + counting = false; + } + }, + chars: () => chars, + }; +} + +interface Replay { + /** Source characters projected, summed over every commit. */ + projected: number; + source: number; + /** Largest single projection, in source characters. */ + worst: number; +} + +/** + * Mirrors the view pipeline: one cache per `runKey`, as `SelectableMarkdown` + * keys its `RunView`s. + */ +function replay(source: string, chunk: number, options: EngineOptions): Replay { + const gauge = meter(); + const caches = new Map(); + const session = new StreamSession({ options }); + let worst = 0; + + const draw = (): void => { + const snapshot = session.snapshot(); + const doc = snapshot.document; + const runs = segmentRuns(doc, { + settledUntil: snapshot.settledUntil, + embed: gauge.embed, + liveTail: true, + }); + runs.forEach((run, index) => { + if (run.standalone) { + return; + } + const key = runKey(run, index, runs.length, true); + let cache = caches.get(key); + if (cache === undefined) { + cache = createRunProjectionCache(); + caches.set(key, cache); + } + const before = gauge.chars(); + gauge.measure(() => cache.project(run, doc, { embed: gauge.embed })); + worst = Math.max(worst, gauge.chars() - before); + }); + }; + + for (let at = 0; at < source.length; at += chunk) { + session.append(source.slice(at, at + chunk)); + draw(); + } + session.finalize(); + draw(); + + return { projected: gauge.chars(), source: source.length, worst }; +} + +function transcript(minimumLength: number): string { + const parts = fs + .readdirSync(FIXTURE_DIR) + .filter((f) => f.endsWith('.md')) + .map((f) => fs.readFileSync(path.join(FIXTURE_DIR, f), 'utf8')); + let out = ''; + while (out.length < minimumLength) { + for (const part of parts) { + out += `${part.trim()}\n\n`; + if (out.length >= minimumLength) break; + } + } + return out; +} + +describeNative('projected-character amplification', () => { + const CHUNK = 18; + + it('stays flat as the streamed document doubles', () => { + const small = replay(transcript(7000), CHUNK, presets.llmChat); + const large = replay(transcript(14000), CHUNK, presets.llmChat); + + const smallAmp = small.projected / small.source; + const largeAmp = large.projected / large.source; + + // Amplification is set by how long a block stays the live tail, so by delta + // size, not document size. + expect(largeAmp).toBeLessThan(smallAmp * 1.35); + expect(largeAmp).toBeLessThan(40); + }); + + it('bounds the largest single projection well below the document', () => { + const doc = transcript(14000); + const { worst, source } = replay(doc, CHUNK, presets.llmChat); + + expect(worst).toBeLessThan(source / 4); + }); +}); diff --git a/conformance/selection/projection-oracle.test.ts b/conformance/selection/projection-oracle.test.ts index 4a43a3f..433de85 100644 --- a/conformance/selection/projection-oracle.test.ts +++ b/conformance/selection/projection-oracle.test.ts @@ -96,6 +96,23 @@ function proseRuns(doc: ParsedDocument): { run: RunSegment; projected: Projected .map((run) => ({ run, projected: projectRun(run, doc) })); } +/** + * The only one-for-one substitutions a linear piece may make, keyed by source + * character. + */ +const PROJECTED_SUBSTITUTIONS: ReadonlyMap = new Map([ + // A soft break renders as a space: docs/FABRIC-PLAN.md §6.1(a). + ['\n', ' '], + ['\r', ' '], + // Smart quotes: which one depends on the preceding character. + ['"', '“”'], + ["'", '‘’'], + ['\u0000', '\ufffd'], + // md4c emits an indented code block's leftover indent columns as spaces + // (md4c.c `indent_chunk_str`), one-for-one only when columns equal tabs. + ['\t', ' '], +]); + function selectionOffsets(length: number): number[] { if (length <= EXHAUSTIVE_LENGTH_LIMIT) { return Array.from({ length: length + 1 }, (_, i) => i); @@ -106,6 +123,67 @@ function selectionOffsets(length: number): number[] { return offsets; } +/** + * Screen and copy legitimately differ on punctuation and whitespace, never on + * letters. + */ +function lettersAndDigits(text: string): string { + return text.replace(/[^\p{L}\p{N}]+/gu, ''); +} + +function isSubsequence(needle: string, haystack: string): boolean { + let i = 0; + for (let j = 0; j < haystack.length && i < needle.length; j += 1) { + if (haystack[j] === needle[i]) i += 1; + } + return i === needle.length; +} + +/** + * The slice minus synthetic glyphs (`piece.source === null`), which have no + * source to copy. + */ +function sourceBackedSlice( + projected: ProjectedRun, + start: number, + end: number, +): string { + let shown = ''; + for (const piece of projected.pieces) { + if (piece.source === null) continue; + const from = Math.max(piece.textStart, start); + const to = Math.min(piece.textEnd, end); + if (to > from) shown += projected.text.slice(from, to); + } + return shown; +} + +/** + * Verbatim text (`ö` is six characters on screen) re-parses differently + * once copied out. + */ +function verbatimRanges(doc: ParsedDocument): { start: number; end: number }[] { + const ranges: { start: number; end: number }[] = []; + visit(doc, (node) => { + if ( + node.kind === 'codeSpan' || + node.kind === 'codeBlock' || + node.kind === 'htmlSpan' || + node.kind === 'htmlBlock' + ) { + ranges.push({ start: node.span.start, end: node.span.end }); + } + return undefined; + }); + return ranges; +} + +/** + * HTML blocks, fences, and link reference definitions mean something else in a + * slice parsed alone. + */ +const OPENS_AN_UNFINISHABLE_BLOCK = /^ {0,3}(?:<|`{3,}|~{3,}|\[[^\]\n]*\]:)/m; + describeNative.each([ ['llmChat', presets.llmChat], ['everything', presets.everything], @@ -138,6 +216,28 @@ describeNative.each([ expect(runs).toBeGreaterThan(400); }); + /** + * An empty flowing run draws nothing; this also guards `emitsOwnText` in + * runs.ts, which mirrors the projector by hand. + */ + it('every flowing run projects non-empty text', () => { + let runs = 0; + for (const { label, source } of corpus) { + const doc = parseDocument(source, options); + for (const { run, projected } of proseRuns(doc)) { + runs++; + if (projected.text.length === 0) { + const kinds = run.blocks.map((block) => block.kind).join(', '); + throw new Error( + `${label}: a flowing run over [${kinds}] projects the empty ` + + 'string, so it would draw nothing at all', + ); + } + } + } + expect(runs).toBeGreaterThan(400); + }); + it('every mapped selection is an in-bounds, ordered source span', () => { let mapped = 0; for (const { label, source } of corpus) { @@ -167,9 +267,16 @@ describeNative.each([ expect(mapped).toBeGreaterThan(100_000); }); - it('the copied markdown is exactly the source slice the selection mapped to', () => { + /** + * Subsequence, not equality: copy may add markers and respell punctuation, + * but never drop a swept letter. + */ + it('copy returns exactly the mapped source slice, and loses nothing swept', () => { + let copies = 0; + let checked = 0; for (const { label, source } of corpus) { const doc = parseDocument(source, options); + const verbatim = verbatimRanges(doc); for (const { projected } of proseRuns(doc)) { const offsets = selectionOffsets(projected.text.length); for (const start of offsets) { @@ -178,16 +285,158 @@ describeNative.each([ const span = mapSelectionToSource(projected, { start, end }); if (span === null) continue; const payload = buildCopyPayload(doc, span, { options }); + copies++; if (payload.markdown !== doc.source.slice(span.start, span.end)) { throw new Error( `${label}: copy payload markdown is not the source slice for ` + `${JSON.stringify(span)}`, ); } + if (typeof payload.plain !== 'string') { + throw new Error(`${label}: copy payload has no plain text`); + } + if ( + verbatim.some((r) => r.start < span.end && r.end > span.start) || + OPENS_AN_UNFINISHABLE_BLOCK.test(payload.markdown) + ) { + continue; + } + checked++; + const swept = lettersAndDigits( + sourceBackedSlice(projected, start, end), + ); + if (!isSubsequence(swept, lettersAndDigits(payload.plain))) { + throw new Error( + `${label}: selecting [${start},${end}) showed ` + + `${JSON.stringify(projected.text.slice(start, end))} but the ` + + `copy of ${JSON.stringify(span)} reads ` + + `${JSON.stringify(payload.plain)}, which does not carry every ` + + 'letter that was swept', + ); + } + } + } + } + } + expect(copies).toBeGreaterThan(100_000); + // The exclusions must not be what carries the test. + expect(checked).toBeGreaterThan(80_000); + }); + + /** + * Offsets map through a linear piece code unit for code unit, so one pinned + * to the wrong source shifts them all. + */ + it('every linear piece displays the source it is pinned to', () => { + let linear = 0; + let nonLinear = 0; + let indivisibleSource = 0; + for (const { label, source } of corpus) { + const doc = parseDocument(source, options); + for (const { projected } of proseRuns(doc)) { + for (const piece of projected.pieces) { + if (piece.source === null) continue; + const text = projected.text.slice(piece.textStart, piece.textEnd); + const src = doc.source.slice(piece.source.start, piece.source.end); + // A non-linear piece (decoded entity, alt text, embed) is indivisible + // by design. + if (text.length !== src.length) { + nonLinear++; + indivisibleSource += src.length; + continue; + } + linear++; + for (let i = 0; i < src.length; i++) { + if (src[i] === text[i]) continue; + const allowed = PROJECTED_SUBSTITUTIONS.get(src[i]); + if (allowed !== undefined && allowed.includes(text[i])) continue; + throw new Error( + `${label}: linear piece ${JSON.stringify(piece)} shows ` + + `${JSON.stringify(text)} for source ${JSON.stringify(src)}`, + ); } } } } + expect(linear).toBeGreaterThan(1_000); + // Gate indivisible source characters, never the piece count: splitting a + // piece raises the count and is an improvement. + if (indivisibleSource >= 850) { + throw new Error( + `${indivisibleSource} source characters are pinned indivisibly, over ` + + `${nonLinear} non-linear pieces — the ceiling is 850 characters, and ` + + 'there is none on the piece count', + ); + } + }); + + /** + * Block syntax projects no text, so `ProjectedExtent` must restore it; + * containment, since a wider hull loses nothing. + */ + it('a whole-block selection maps to a span covering that block', () => { + const syntaxBearing = new Set([ + 'heading', + 'blockquote', + 'list', + 'codeBlock', + 'table', + ]); + let checked = 0; + for (const { label, source } of corpus) { + const doc = parseDocument(source, options); + for (const { run } of proseRuns(doc)) { + for (const block of run.blocks) { + if (!syntaxBearing.has(block.kind)) continue; + const projected = projectRun({ ...run, blocks: [block] }, doc); + if (projected.text.length === 0) continue; + const span = mapSelectionToSource(projected, { + start: 0, + end: projected.text.length, + }); + if (span === null) continue; + checked++; + if (span.start > block.span.start || span.end < block.span.end) { + throw new Error( + `${label}: selecting the whole of a ${block.kind} mapped to ` + + `${JSON.stringify(span)}, which does not cover the block's own ` + + `span ${JSON.stringify(block.span)} — its markers would be ` + + 'lost from the copy', + ); + } + } + } + } + expect(checked).toBeGreaterThan(200); + }); + + /** + * A census, not an assertion: re-parse equality is still false for whole + * classes of block; the floor catches lossier copy. + */ + it('records how many whole-block copies come back as themselves', () => { + let blocks = 0; + let roundTrips = 0; + for (const { source } of corpus) { + const doc = parseDocument(source, options); + for (const { run } of proseRuns(doc)) { + for (const block of run.blocks) { + const projected = projectRun({ ...run, blocks: [block] }, doc); + if (projected.text.length === 0) continue; + const span = mapSelectionToSource(projected, { + start: 0, + end: projected.text.length, + }); + if (span === null) continue; + blocks++; + if (buildCopyPayload(doc, span, { options }).plain === projected.text) { + roundTrips++; + } + } + } + } + expect(blocks).toBeGreaterThan(700); + expect(roundTrips).toBeGreaterThan(680); }); }); @@ -394,13 +643,25 @@ describeNative.each([ JSON.stringify(span), ); } - const payload = buildCopyPayload(doc, span, { options }); + const payload = buildCopyPayload(doc, span, { + options, + // `plain` must show placeholders where the screen does; the + // lookup sees nodes reparsed from the slice, so it claims by + // shape. + embed: claimLinks, + }); if (payload.markdown !== doc.source.slice(span.start, span.end)) { throw new Error( `${label}: embed copy payload is not the source slice for ` + JSON.stringify(span), ); } + if (payload.plain.includes('\ufffc')) { + throw new Error( + `${label}: embed copy payload left a raw placeholder in plain: ` + + JSON.stringify(payload.plain), + ); + } } } } diff --git a/conformance/streaming/prefix-oracle.test.ts b/conformance/streaming/prefix-oracle.test.ts index ff3c30b..52801bb 100644 --- a/conformance/streaming/prefix-oracle.test.ts +++ b/conformance/streaming/prefix-oracle.test.ts @@ -40,10 +40,12 @@ import { visit } from '../../src/document/visit'; import type { Engine } from '../../src/engine/Engine'; import { parseDocument } from '../../src/engine/Engine'; import type { EngineOptions } from '../../src/engine/options'; -import { presets } from '../../src/engine/options'; +import { DEFAULT_LINK_PREFIXES, presets } from '../../src/engine/options'; import { describeNative, requireNativeEngine } from '../../src/engine/native/__tests__/support'; import { trimTrailingPlaceholders } from '../../src/stream/placeholders'; +import type { BufferScheduler, IdleScheduler } from '../../src/stream/StreamSession'; import { StreamSession } from '../../src/stream/StreamSession'; +import { createSmoother } from '../../src/stream/smoothing'; const FIXTURE_DIR = path.resolve(__dirname, '..', 'fixtures'); const PER_FIXTURE_TIMEOUT_MS = 120_000; @@ -68,11 +70,7 @@ const TRANSCRIPT: Transcript = JSON.parse( fs.readFileSync(path.join(FIXTURE_DIR, 'transcript-sprint-review.json'), 'utf8'), ); -/** - * Fixtures that contain a GFM table. They are called out because they are - * what found the decoder's worst defect, and they are the cases most likely - * to find its successor. - * +/* * A table row with fewer cells than the header gets a padding cell that md4c * reports with no text. When that row is the LAST thing in the source — * which every streamed table passes through, cell by cell, as it is typed — @@ -83,10 +81,6 @@ const TRANSCRIPT: Transcript = JSON.parse( * comparison-table.md, 148 in mixed-longform.md. Empty cells are now placed * from their own row's pipes, and these run as ordinary tests. */ -const TABLE_FIXTURES: ReadonlySet = new Set([ - 'comparison-table.md', - 'mixed-longform.md', -]); function countSpoilers(doc: ParsedDocument): number { let count = 0; @@ -123,6 +117,33 @@ function expectedDisplay(doc: ParsedDocument, settledUntil: number): Block[] { ]; } +/** + * `'buffered'` commits at holdback and smoothing cuts, prefixes the + * per-code-point sweep never produces. + */ +type FeedMode = 'append' | 'buffered'; + +function manualScheduler() { + let next: (() => void) | null = null; + return { + schedule(flush: () => void): () => void { + next = flush; + return () => { + next = null; + }; + }, + /** + * Clears before firing, so a re-schedule from inside the flush survives. + */ + fire(): boolean { + const f = next; + next = null; + f?.(); + return f !== null; + }, + }; +} + interface OracleResult { readonly final: ReturnType; readonly snapshotCount: number; @@ -150,6 +171,7 @@ function streamDeltas( options: EngineOptions | undefined, engine: Engine, verifyPrefixes = true, + mode: FeedMode = 'append', ): OracleResult { let snapshotCount = 0; let spoilerSnapshots = 0; @@ -158,7 +180,25 @@ function streamDeltas( // Once a block has entered the settled prefix, every subsequent snapshot // must contain the very same object (===) for that kind+span. const settledSeen = new Map(); - const session = new StreamSession({ options, engine }); + const frame = manualScheduler(); + const idle = manualScheduler(); + // 1200cps over 16ms frames releases ~19 units a flush, so most flushes + // commit a partial construct. + let clock = 0; + const bufferScheduler: BufferScheduler = (flush) => frame.schedule(flush); + const idleScheduler: IdleScheduler = (flush) => idle.schedule(flush); + const session = + mode === 'append' + ? new StreamSession({ options, engine }) + : new StreamSession({ + options, + engine, + bufferScheduler, + idleScheduler, + holdBackChars: 4, + now: () => clock, + smoother: createSmoother({ charsPerSecond: 1200, now: () => clock }), + }); const unsubscribe = session.subscribe((snap) => { snapshotCount += 1; @@ -198,7 +238,23 @@ function streamDeltas( } }); - for (const delta of deltas) session.append(delta); + if (mode === 'append') { + for (const delta of deltas) session.append(delta); + } else { + for (const delta of deltas) { + session.appendBuffered(delta); + clock += 16; + frame.fire(); + } + // Drain at frame cadence rather than through finalize: those flushes are + // prefixes too. + for (let guard = 0; session.pendingLength > 0 && guard < 20_000; guard += 1) { + clock += 16; + if (!frame.fire() && !idle.fire()) break; + } + } + // `length`, not `+ pendingLength`: text a holdback or smoother stranded must + // fail here, not be drained by finalize. const fedLength = session.length; session.finalize('end'); unsubscribe(); @@ -222,44 +278,73 @@ function codePoints(text: string): string[] { describeNative('streaming prefix oracle', () => { const engine = (): Engine => requireNativeEngine(); - test('fixture directory has the expected corpus', () => { - expect(fixtureFiles.length).toBeGreaterThanOrEqual(6); - expect(TRANSCRIPT.deltas.length).toBeGreaterThan(50); - // The table fixtures are the regression surface for the padding-cell - // defect; if they ever stop being part of the corpus, this sweep quietly - // stops covering the case that broke every streamed table. - for (const file of TABLE_FIXTURES) expect(fixtureFiles).toContain(file); - }); + /** + * `smartPunctuation` and `html: 'raw'` decouple a text node's value from its + * source, the condition `StreamSession.tryFastPath` stands down on. + */ + const FIXTURE_OPTION_SETS: ReadonlyArray<{ + name: string; + options: EngineOptions; + }> = [ + { name: 'llmChat', options: presets.llmChat }, + { + name: 'smartPunctuation', + options: { ...presets.llmChat, smartPunctuation: true }, + }, + { name: "html:'raw'", options: { ...presets.llmChat, html: 'raw' } }, + ]; for (const file of fixtureFiles) { describe(file, () => { const full = fs.readFileSync(path.join(FIXTURE_DIR, file), 'utf8'); + for (const { name, options } of FIXTURE_OPTION_SETS) { + test( + `every prefix splices to what a fresh native parse of the same source displays (${name})`, + () => { + const result = streamDeltas(codePoints(full), options, engine()); + + expect(result.fedLength).toBe(full.length); + expect(result.final.phase).toBe('settled'); + expect(result.final.document.source).toBe(full); + expect(result.prefixViolations).toEqual([]); + // Non-vacuous: every fixture is multi-block, so blocks settle + // mid-stream. + expect(result.settledBlocksSeen).toBeGreaterThan(0); + expect(result.identityViolations).toEqual([]); + // Spoilers are off in every set, so one at any prefix means the + // option leaked. + expect(result.spoilerSnapshots).toBe(0); + expect(countSpoilers(result.final.document)).toBe(0); + + // Deep equality also fails on a surviving incomplete/synthetic + // repair marker. + expect(result.final.document).toEqual( + parseDocument(full, options, engine()), + ); + }, + PER_FIXTURE_TIMEOUT_MS, + ); + } + test( - 'every prefix splices to what a fresh native parse of the same source displays', + 'the buffered entry point splices the same way, holdback and smoothing included', () => { - const result = streamDeltas(codePoints(full), presets.llmChat, engine()); + const result = streamDeltas( + codePoints(full), + presets.llmChat, + engine(), + true, + 'buffered', + ); expect(result.fedLength).toBe(full.length); - expect(result.final.phase).toBe('settled'); expect(result.final.document.source).toBe(full); expect(result.prefixViolations).toEqual([]); - // Frozen blocks must keep referential identity in every later - // snapshot, including the one finalize produces — and the check - // must not be vacuous: every fixture is multi-block, so blocks do - // settle mid-stream. - expect(result.settledBlocksSeen).toBeGreaterThan(0); expect(result.identityViolations).toEqual([]); - // Spoilers are off in every preset, and the transform runs after the - // engine on every reparse — so a spoiler appearing at ANY prefix - // would mean the option leaked, not that the final document is - // wrong. Checked per snapshot for that reason. - expect(result.spoilerSnapshots).toBe(0); - expect(countSpoilers(result.final.document)).toBe(0); - - // Deep equality includes the ABSENCE of incomplete/synthetic: a - // fresh parse never carries them, so a surviving repair marker - // fails here. + expect(result.settledBlocksSeen).toBeGreaterThan(0); + // Without coalescing, buffering exercises nothing append does not. + expect(result.snapshotCount).toBeLessThan(full.length); expect(result.final.document).toEqual( parseDocument(full, presets.llmChat, engine()), ); @@ -270,7 +355,7 @@ describeNative('streaming prefix oracle', () => { } // The transcript contains a table, which is what made this case (and - // TABLE_FIXTURES above) the ones that found the padding-cell defect. + // the table fixtures above) the ones that found the padding-cell defect. test( 'the LLM transcript replays delta-by-delta to the fresh-parse document', () => { @@ -294,8 +379,7 @@ describeNative('streaming prefix oracle', () => { 'the transcript reaches the same document however it is chunked', () => { // Nothing about the splice may depend on where a delta happened to be - // cut. Prefix verification is off here (the transcript's table trips - // the known bug); the final documents and settled identity are the + // cut. Final documents and settled identity are the // properties this case is about, and both hold today. const full = TRANSCRIPT.deltas.join(''); const byChunk = streamDeltas(TRANSCRIPT.deltas, presets.llmChat, engine(), false); @@ -332,6 +416,120 @@ describeNative('streaming prefix oracle', () => { PER_FIXTURE_TIMEOUT_MS, ); + test( + 'a streamed spoiler never paints its body in the clear', + () => { + // Without the tail repair closing an unpaired '||', the body renders in + // the clear until the closer arrives. + const src = 'The answer is ||hunter2|| and nothing else.\n'; + const secretStart = src.indexOf('hunter2'); + const secretEnd = secretStart + 'hunter2'.length; + const options: EngineOptions = { + extensions: { ...ALL_ON.extensions, spoilers: true }, + }; + + const leaks: string[] = []; + let covered = 0; + const session = new StreamSession({ options, engine: engine() }); + const unsubscribe = session.subscribe((snap) => { + const hidden: { start: number; end: number }[] = []; + visit(snap.document, (n) => { + if (n.kind === 'spoiler') hidden.push(n.span); + }); + let sawSecret = false; + visit(snap.document, (n) => { + if (n.kind !== 'text') return; + if (n.span.start >= secretEnd || n.span.end <= secretStart) return; + sawSecret = true; + const inside = hidden.some( + (h) => h.start <= n.span.start && h.end >= n.span.end, + ); + if (!inside) { + leaks.push(`rev ${snap.revision}: ${JSON.stringify(n.value)} outside every spoiler`); + } + }); + if (sawSecret) covered += 1; + }); + for (const cp of codePoints(src)) session.append(cp); + session.finalize('end'); + unsubscribe(); + + expect(leaks).toEqual([]); + // Non-vacuous: the secret was on screen for most of the stream. + expect(covered).toBeGreaterThan(5); + expect(countSpoilers(session.snapshot().document)).toBe(1); + + const result = streamDeltas(codePoints(src), options, engine()); + expect(result.prefixViolations).toEqual([]); + expect(result.identityViolations).toEqual([]); + expect(result.spoilerSnapshots).toBeGreaterThan(0); + expect(result.final.document).toEqual(parseDocument(src, options, engine())); + }, + PER_FIXTURE_TIMEOUT_MS, + ); + + test.each([ + [ + 'a paragraph', + 'The answer is:\n\n||42 is the answer|| and nothing else.\n', + '42 is the answer', + ], + [ + 'a list item', + 'Answers:\n\n- ||hunter2|| first\n- plain second\n', + 'hunter2', + ], + ])( + 'a spoiler opening its line never paints its body in the clear (%s)', + (_shape, src, secret) => { + // A line opening with a pipe is a table row only under a header with a + // delimiter row, as the parse decides it. + const secretStart = src.indexOf(secret); + const secretEnd = secretStart + secret.length; + const options: EngineOptions = { + extensions: { ...ALL_ON.extensions, spoilers: true }, + }; + + const leaks: string[] = []; + let covered = 0; + const session = new StreamSession({ options, engine: engine() }); + const unsubscribe = session.subscribe((snap) => { + const hidden: { start: number; end: number }[] = []; + visit(snap.document, (n) => { + if (n.kind === 'spoiler') hidden.push(n.span); + }); + let sawSecret = false; + visit(snap.document, (n) => { + if (n.kind !== 'text') return; + if (n.span.start >= secretEnd || n.span.end <= secretStart) return; + sawSecret = true; + const inside = hidden.some( + (h) => h.start <= n.span.start && h.end >= n.span.end, + ); + if (!inside) { + leaks.push( + `rev ${snap.revision}: ${JSON.stringify(n.value)} outside every spoiler`, + ); + } + }); + if (sawSecret) covered += 1; + }); + for (const cp of codePoints(src)) session.append(cp); + session.finalize('end'); + unsubscribe(); + + expect(leaks).toEqual([]); + expect(covered).toBeGreaterThan(5); + expect(countSpoilers(session.snapshot().document)).toBe(1); + + const result = streamDeltas(codePoints(src), options, engine()); + expect(result.prefixViolations).toEqual([]); + expect(result.identityViolations).toEqual([]); + expect(result.final.document).toEqual(parseDocument(src, options, engine())); + }, + PER_FIXTURE_TIMEOUT_MS, + ); + const ALL_ON: EngineOptions = { extensions: { tables: true, @@ -349,8 +547,7 @@ describeNative('streaming prefix oracle', () => { () => { // Task lists, math, underline and fences each reach the splice through // a different span-widening path, and the default preset exercises - // neither math nor underline. Tables are deliberately absent: they trip - // the known bug above and are covered by the failing cases instead. + // neither math nor underline. const full = [ '# Everything\n\n', '> - [x] quoted **task** with `code`\n> - [ ] and $x^2$\n\n', @@ -368,6 +565,157 @@ describeNative('streaming prefix oracle', () => { PER_FIXTURE_TIMEOUT_MS, ); + test( + 'raw HTML blocks splice the same way, blank lines and all', + () => { + // HTML blocks of types 1-5 (`\n\n', + '
\n type 6 ends at the blank line\n
\n\n', + '\n\n', + 'Closing prose.\n', + ].join(''); + const result = streamDeltas(codePoints(full), options, engine()); + expect(result.prefixViolations).toEqual([]); + expect(result.identityViolations).toEqual([]); + expect(result.settledBlocksSeen).toBeGreaterThan(0); + expect(result.final.document).toEqual( + parseDocument(full, options, engine()), + ); + }, + PER_FIXTURE_TIMEOUT_MS, + ); + + test( + 'bare ftp autolinks and list-item fences splice the same way', + () => { + // md4c autolinks bare ftp too (allowlisted, or it degrades to text and + // hides the divergence); a fence on a list-marker line is an opener. + const options: EngineOptions = { + ...presets.llmChat, + urlPolicy: { linkPrefixes: [...DEFAULT_LINK_PREFIXES, 'ftp://'] }, + }; + const full = [ + 'Mirrors live at ftp://example.com/pub and https://example.com/pub.\n\n', + '- ```sh\n curl ftp://example.com/pub/file.txt\n ```\n', + '- and a second item with a _partial word\n\n', + 'Closing prose after the list.\n', + ].join(''); + const result = streamDeltas(codePoints(full), options, engine()); + expect(result.prefixViolations).toEqual([]); + expect(result.identityViolations).toEqual([]); + expect(result.settledBlocksSeen).toBeGreaterThan(0); + expect(result.final.document).toEqual( + parseDocument(full, options, engine()), + ); + }, + PER_FIXTURE_TIMEOUT_MS, + ); + + test( + 'a spoiler-enabled sweep splices the same way at every prefix', + () => { + // Spoilers are the one post-engine transform, so the one the splice + // could disagree with; ALL_ON pins them off. + const options: EngineOptions = { + extensions: { ...ALL_ON.extensions, spoilers: true }, + }; + const full = [ + 'Intro paragraph before anything hidden.\n\n', + 'The answer is ||hunter2|| and the runner-up is ||nobody||.\n\n', + '- a list item with ||a hidden phrase|| inside\n', + '- and one with a lone || that never closes\n\n', + '| col | value |\n| :- | -: |\n| a | ||secret|| |\n\n', + 'Closing prose.\n', + ].join(''); + const result = streamDeltas(codePoints(full), options, engine()); + expect(result.prefixViolations).toEqual([]); + expect(result.identityViolations).toEqual([]); + expect(result.settledBlocksSeen).toBeGreaterThan(0); + expect(result.spoilerSnapshots).toBeGreaterThan(0); + const fresh = parseDocument(full, options, engine()); + expect(countSpoilers(result.final.document)).toBe(countSpoilers(fresh)); + expect(countSpoilers(fresh)).toBeGreaterThan(0); + expect(result.final.document).toEqual(fresh); + }, + PER_FIXTURE_TIMEOUT_MS, + ); + + test( + 'a link reference definition arriving after a settled paragraph splices the same way', + () => { + // A definition acts at a distance: it turns `[foo]` in a paragraph the + // anchor froze long before into a link. + const full = [ + 'See [foo] here.\n\n', + 'A middle paragraph that settles.\n\n', + '[foo]: https://example.com/foo\n', + '[bar]: https://example.com/bar "titled"\n\n', + 'And [bar] afterwards.\n', + ].join(''); + const result = streamDeltas(codePoints(full), presets.llmChat, engine()); + expect(result.prefixViolations).toEqual([]); + expect(result.final.document).toEqual( + parseDocument(full, presets.llmChat, engine()), + ); + // Only the first paragraph's parse changed, so only it loses identity: + // the session drops the anchor, not the identity cache. + expect( + result.identityViolations.map((v) => v.replace(/^rev \d+: /, '')), + ).toEqual(['settled block paragraph:0:15 lost referential identity']); + let links = 0; + visit(result.final.document, (n) => { + if (n.kind === 'link') links += 1; + }); + expect(links).toBe(2); + const buffered = streamDeltas( + codePoints(full), + presets.llmChat, + engine(), + true, + 'buffered', + ); + expect(buffered.prefixViolations).toEqual([]); + expect(buffered.final.document).toEqual(result.final.document); + }, + PER_FIXTURE_TIMEOUT_MS, + ); + + test( + 'a definition behind a list item content indent splices the same way', + () => { + // md4c honours a definition at a list item's content column; the scan + // must not read those four spaces as indented code, at any granularity. + const full = [ + 'See [foo] here.\n\n', + '- outer\n - inner\n\n', + ' [foo]: https://example.com/foo\n\n', + 'And more prose afterwards.\n', + ].join(''); + for (const deltas of [ + codePoints(full), + full.split(/(?<=\n)/), + [full], + ]) { + const result = streamDeltas(deltas, presets.llmChat, engine()); + expect(result.prefixViolations).toEqual([]); + expect(result.final.document).toEqual( + parseDocument(full, presets.llmChat, engine()), + ); + } + let links = 0; + visit(parseDocument(full, presets.llmChat, engine()), (n) => { + if (n.kind === 'link') links += 1; + }); + expect(links).toBe(1); + }, + PER_FIXTURE_TIMEOUT_MS, + ); + test( 'a streamed table splices the same way', () => { @@ -386,14 +734,19 @@ describeNative('streaming prefix oracle', () => { test('finalize is idempotent on a native-backed session', () => { const session = new StreamSession({ options: presets.llmChat, engine: engine() }); session.append('Almost **done'); - const lengthBefore = session.length; session.append(''); - expect(session.length).toBe(lengthBefore); + expect(session.length).toBe(13); session.finalize('aborted'); const first = session.snapshot(); + const notified: number[] = []; + const unsubscribe = session.subscribe((snap) => notified.push(snap.revision)); session.finalize('aborted'); + unsubscribe(); const second = session.snapshot(); + expect(notified).toEqual([]); expect(second.phase).toBe('settled'); - expect(second.document).toEqual(first.document); + expect(second.revision).toBe(first.revision); + expect(second.document).toEqual(parseDocument('Almost **done', presets.llmChat, engine())); + expect(JSON.stringify(second.document.blocks.map((b) => b.kind))).toBe('["paragraph"]'); }); }); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f4e7b21..ff51165 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,7 +14,7 @@ markdown source (static string, or accumulated stream text) v engine/ Engine interface: the pluggable parser native/ md4c behind one ArrayBuffer per parse (default, and the only parser) - entities.ts, urlPolicy.ts shared decode + URL policy + entities.ts, urlPolicy.ts fallback entity decode + URL policy extensions/spoilers.ts opt-in post-parse transform v document/ ParsedDocument; every node: { kind, span: SourceSpan } @@ -27,13 +27,14 @@ selection/ segmentRuns: adjacent flowing blocks -> selectable runs v view/ SelectableMarkdown runs -> RunHost (native SelectableRunHost; required, throws where not linked) - standalone blocks (classifyBlock) -> block renderers + standalone blocks (unclaimed image, spoiler, a classifyBlock claim) -> block renderers, + nesting capped at MAX_RENDER_DEPTH (64); deeper subtrees render flat text per-run memoization keyed on span + settled identity v onSelectionCopy({ action, plain, markdown, span }) <- selection offsets (UTF-16) from the host ``` -Pacing (`appendBuffered`, holdback, smoothers) sits in front of the stream layer and feeds the same `append` path, so nothing below it knows coalescing exists. The stream layer sits in front of the engine: tail repair is a text-to-text transform, so any engine gets artifact-free streaming for free. Selection never touches the render tree; it maps display offsets to source spans through the piece map, and the React tree is only a projection. +Pacing (`appendBuffered`, holdback, smoothers) sits in front of the stream layer and feeds the same `append` path, so nothing below it knows coalescing exists. The stream layer sits in front of the engine: tail repair is a text-to-text transform, and the constructs it completes are CommonMark's. Selection never touches the render tree; it maps display offsets to source spans through the piece map, and the React tree is only a projection. ## Three rules the design follows @@ -50,7 +51,7 @@ An engine that cannot produce exact spans cannot back this library. Span correct ### 2. Streaming is incremental, not a re-render loop -`StreamSession` owns the accumulated text. After each parse it finds a safe anchor: the end of the last block no future append can change. Each append reparses only `source.slice(anchor)`, rebases the spans (`shiftSpans`, a deep clone), and splices the frozen prefix back in by reference. Settled blocks keep referential identity, so settled runs never re-render, and parse cost tracks the tail rather than the document. A plain-prose delta skips the engine and extends the trailing paragraph. When nothing can anchor yet (one giant list, an unclosed fence), the update falls back to a correct full reparse. +`StreamSession` owns the accumulated text. After each parse it finds a safe anchor: the end of the last block no future append can change. Each append reparses only `source.slice(anchor)`, rebases the spans (`shiftSpans`, a deep clone), and splices the frozen prefix back in by reference. Settled blocks keep referential identity, so an untouched settled run re-renders zero times and the one run that absorbs a newly settled block re-renders once per settle; parse cost tracks the tail rather than the document. A plain-prose delta skips the engine and extends the trailing paragraph. When nothing can anchor yet (one giant list, an unclosed fence), the update falls back to a correct full reparse. Before each parse, `repairTail` virtually completes unfinished constructs (`**bold`, `[link](https://…`) and suppresses lines that would flip earlier structure (a lone `-` about to make a setext heading). On `finalize` the repairs vanish and the document is a plain parse of exactly what arrived. A prefix oracle asserts at every prefix of every fixture that the spliced snapshot equals a fresh parse. Details in [STREAMING.md](STREAMING.md). @@ -58,15 +59,15 @@ Before each parse, `repairTail` virtually completes unfinished constructs (`**bo Every non-CommonMark extension is opt-in (`ExtensionFlags`, all false by default). A stray `|` in model output must not change how a paragraph renders, so `||…||` is off in every preset except `everything`, and even then only balanced pairs parse. -HTML is stripped by default (`html: 'strip'`). URL schemes are allowlisted (links: `https://`, `http://`, `mailto:`; images: `https://`) at parse time, so no renderer can forget. Blocked links degrade to plain text, blocked images to their alt text. +HTML is stripped by default (`html: 'strip'`), which drops the construct *and* the text inside it: an HTML block contributes nothing to the document. The one exception is an inline `
`, which decodes to a `hardBreak` over the tag rather than vanishing between two words. URL schemes are allowlisted (links: `https://`, `http://`, `mailto:`; images: `https://`). `nativeEngine`'s decoder applies the allowlist as it builds each node, so *that* engine can never return a rejected `href` and no renderer can forget; `parseDocument` runs no policy pass over a substituted engine's output, so the view checks again at the navigation boundary — `openUrl` sanitizes and re-tests every href against the document's resolved `urlPolicy.linkPrefixes` before `Linking.openURL`, on the renderer path and the native-run press path alike. Matching folds case over the scheme and authority only, and a consumer prefix that reaches into a path (`myapp://checkout/`) is treated as a scope: a destination that walks back out of it with `..` (raw or percent-encoded once) is refused. Blocked links degrade to plain text, blocked images to their alt text. -A blocked link leaves no node behind, so an app whose model emits in-app identifiers as links cannot render them. `urlPolicy.blockedLinks: 'node'` keeps the node, flagged `blocked: true`, for your `link` renderer. The href still never reaches `openURL`. Autolinks stay outside this: their text is their destination, so there is no label to keep. +A blocked link leaves no node behind, so an app whose model emits in-app identifiers as links cannot render them. `urlPolicy.blockedLinks: 'node'` keeps the node, flagged `blocked: true`. Inside a flowing run that node projects as a `blockedLink` mark and your `link` renderer does not run — renderers draw standalone blocks only. The channels that reach it there are `attributeForMark`, `onLinkPress` and `embed`; `classifyBlock: 'standalone'` is what puts the block back on the renderer path. The href still never reaches `openURL` on any of them. Autolinks and images stay outside this — `blockedLinks` governs inline links only: an autolink's text is its destination, so there is no label to keep, and a blocked image degrades to its alt text under either setting. ## Module map ``` src/ - index.ts public API barrel + index.ts public API barrel: explicit named exports, internals stay on deep paths document/ span.ts SourceSpan + span algebra (length/contains/intersects/slice) nodes.ts Block/Inline unions, ParsedDocument, type guards @@ -74,8 +75,8 @@ src/ engine/ Engine.ts Engine interface + parseDocument() options.ts ExtensionFlags, EngineOptions, resolveOptions, presets - entities.ts entity/escape decoding for hrefs, titles, info strings (internal) - urlPolicy.ts sanitizeUrl + isUrlAllowed, applied at parse time (internal) + entities.ts fallback entity decoding for the decoder's Entity case (internal) + urlPolicy.ts sanitizeUrl + isUrlAllowed, applied at parse time, re-checked at press native.ts re-export facade for the md4c engine native/ md4c binding: protocol.ts, decode.ts, widen.ts, install.ts, index.ts extensions/spoilers.ts applySpoilers(): opt-in post-parse ||…|| transform @@ -85,9 +86,13 @@ src/ shiftSpans.ts span rebasing for the incremental splice smoothing.ts createSmoother()/createAdaptiveSmoother() placeholders.ts trimTrailingPlaceholders() + clusters.ts grapheme-cluster boundaries, so a release cut never splits a glyph selection/ - runs.ts segmentRuns(), classifyBlock() -> RunSegment[] - mapSelection.ts projectRun(), mapSelectionToSource() + runs.ts segmentRuns(), classifyTopLevelBlock() -> RunSegment[] + (classification memoized on block identity; runs break at + DEFAULT_MAX_RUN_CHARS; `liveTail` keeps the streaming tail + in a run of its own) + mapSelection.ts projectRun() (incremental through `previous`), mapSelectionToSource() copy.ts buildCopyPayload(): span -> { markdown, plain } view/ SelectableMarkdown.tsx the component @@ -97,9 +102,15 @@ src/ runDecorations.ts marks + theme -> block chrome the host paints runPressables.ts link marks -> tappable ranges the host hit-tests runEmbeds.ts embed entries -> RunEmbed[] the host reserves space from + imageEmbeds.ts withImageEmbeds(): the built-in image embed claim behind `images` + projectionCache.ts createRunProjectionCache(): incremental projectRun per run + processedColors.ts memoizedProcessColor(): the bounded, evicting processColor memo + runIdentity.ts runKey()/embedRectKey(): identities that survive a settle + selectionRange.ts mapSourceToRunRange(): SourceSpan -> a run's display range; + selectSpanInRuns(): the document-level setSelection walk selectionActions.ts handleSelectionAction(): menu event -> onSelectionCopy payload theme.ts grouped tokens, mergeTheme, defaultTheme/defaultDarkTheme - renderers.tsx default per-kind renderers + override types + renderers.tsx default per-kind renderers + override types + the MAX_RENDER_DEPTH cap agui/ useAgUiSession.ts adapter for one known message (no ag-ui dependency) bindRunTextEvents.ts run-scoped binding over per-message sessions @@ -107,17 +118,25 @@ src/ platform/ cpp/ OffsetParser (md4c SAX -> offset nodes), Protocol.h + FlatBuffer.cpp, SelectableMarkdownJsi (installs the parse global), vendor/md4c - fabric/ shared Fabric C++: measuring shadow node, state, descriptor, measurer facade + fabric/ shared Fabric C++: measuring shadow node, state, descriptor, measurer facade, + android-include/ (the three codegen headers Android's CMake shadows) ios/ UITextView host, Fabric component view, iOS measurer, JSI installer android/ TextView host + ViewManager, CMake/JNI glue, JSI installer. At the package root because RN's Gradle plugin looks for package.json one directory up. native/node/ Node-API harness over the same C++ for tests and benches (never shipped) conformance/ CommonMark runner, streaming prefix oracle, projection oracle, fixtures bench/ Node benchmarks +scripts/ build-node-addon (builds the Node harness), emit-dist-spec-shim (the codegen + spec's dist stub), check-codegen / check-fabric-cpp / check-swift, verify-pack, + release, changelog-section +docs/ this file and its siblings +SelectableMarkdown.podspec, react-native.config.js what autolinking reads; both ship ``` Dependencies point downward: `document` depends on nothing; `engine` on `document`; `stream` on both; `selection` on `document` and `engine`; `view` on all of the above; `agui` on `stream` (plus the `EngineOptions` type from `engine`). Nothing in `src/` imports from `platform/`. +`index.ts` names every export one at a time instead of re-exporting modules wholesale, so the surface is a decision rather than a consequence of where a helper happens to live. It publishes the document model plus its span algebra (`visit`, `findAt`, `childrenOf`, `isBlock`/`isInline`, `spanLength` and friends); `parseDocument`, the `Engine` type, `resolveOptions`/`withOptions`/`presets`, `DEFAULT_LINK_PREFIXES`/`DEFAULT_IMAGE_PREFIXES`, `sanitizeUrl`/`isUrlAllowed`, `applySpoilers`, and the native quartet `nativeEngine`/`createNativeEngine`/`installNativeEngine`/`isNativeEngineAvailable` (with `isNativeEngineInstalled` and `isNativeEnginePermanentlyRefused` for diagnostics); `StreamSession`, `createSmoother`/`createAdaptiveSmoother`/`snapPastLinkDestination`, `repairTail`/`seedFromSettled`/`continueSeed`, `trimTrailingPlaceholders`; `segmentRuns`/`classifyTopLevelBlock`/`DEFAULT_MAX_RUN_CHARS`, `projectRun`/`mapSelectionToSource`/`EMBED_PLACEHOLDER`, `buildCopyPayload`; the ag-ui adapters; and the view layer — `SelectableMarkdown`, `RunHost`, the theme, the renderers with `openUrl`/`textContentOf`/`MAX_RENDER_DEPTH`, the selection-action helpers, and the per-channel resolvers a consumer driving `RunHost` itself needs (`resolveRunAttributes`, `resolveRunPressables`, `resolveRunDecorations`, `resolveRunEmbeds`, `withImageEmbeds`, `createRunProjectionCache`, `mapSourceToRunRange`). What it deliberately does not publish stays reachable one directory in: the flat-buffer decoder and `__linkNativeEngine` at `dist/engine/native`, the selection-menu wire codec at `dist/view/selectionActions`. + One file is build input rather than runtime code. React Native's codegen and babel plugin read `SelectableRunHostNativeComponent.ts` and only match `codegenNativeComponent<…>` in the original source; a `tsc`-transpiled copy yields no view config, the component never registers, and `RunHost` throws for every run. So `package.json` points Metro at `src/index.ts`, `tsconfig.build.json` excludes the file from emit, `RunHost` reaches it through a call-expression `require` (an `import type` would defeat the exclusion), and `npm run check:codegen` asserts the generated C++ still matches. ## Engine interface @@ -163,13 +182,14 @@ parseDocument(md, undefined, plainTextEngine); // or: ``` -Three rules, each a way a real engine goes wrong: +Four rules, each a way a real engine goes wrong: 1. **`source` is the string you were handed.** Copy, selection and streaming all slice that string by the spans below it. Returning a trimmed or normalized source breaks all three. 2. **Spans are absolute UTF-16 offsets into that string.** `match.index` is what makes the paragraph above selectable. Per-chunk offsets like `{ start: 0, end: match[0].length }` render fine and copy the wrong text from the second paragraph on. Nothing checks this for you. 3. **Node kinds are a fixed vocabulary.** `Block` and `Inline` are discriminated unions from `src/document/nodes.ts`: `paragraph`, `heading`, `codeBlock`, `blockquote`, `list`, `listItem`, `table`, `tableRow`, `tableCell`, `thematicBreak`, `htmlBlock`; `text`, `emphasis`, `strong`, `strikethrough`, `underline`, `codeSpan`, `link`, `image`, `autolink`, `hardBreak`, `softBreak`, `math`, `spoiler`, `htmlSpan`. The type checker rejects anything else, and `RendererMap` is a mapped type over `AnyNode['kind']`, so a missing renderer is a compile error. +4. **The stream layer speaks CommonMark, not your dialect.** `StreamSession` never hands you the tail as it arrived. `repairTail` (`src/stream/repair.ts`) appends virtual CommonMark closers (`**bold` parses as `**bold**`) and suppresses a bare trailing construct line that would flip the structure above it (a lone `-` about to make a setext heading), and there is no switch that turns it off. The parse-free fast path calls you at all only when a delta holds one of the characters in `CONSTRUCT_CHARS` (`src/stream/StreamSession.ts`) or ends in a space or tab, so an engine whose own construct characters are not a subset of that class — a `@mention` engine, say — shows stale structure until an unrelated construct character happens to arrive. An engine that is not parsing CommonMark is better driven by parsing the accumulated text yourself than through `StreamSession`. -The second parameter is `ResolvedEngineOptions`, with every default filled in. Honouring the flags is optional; the span invariant is not. +The second parameter is `ResolvedEngineOptions`, with every default filled in. Honouring the flags is optional; the span invariant is not. `options.urlPolicy` in particular is yours to apply: nothing between `parseDocument` and the view re-filters a node's `href`, and the view's press-time re-check is a backstop for navigation only, not for the hrefs your own UI reads off a node. ### The engine that ships @@ -180,13 +200,15 @@ The engine is two halves that meet at the buffer. Which half owns a decision is | | C++ (`platform/cpp/`, before the crossing) | TypeScript (`src/engine/native/`, after it) | | --- | --- | --- | | Structure | md4c decides blocks, inlines and GFM extensions | Replays the events; never re-decides structure | -| Offsets | md4c's UTF-8 byte offsets to UTF-16, the one conversion point | Widens content ranges into construct spans (`widen.ts`) | -| Text | None crosses. Events carry offsets, not characters | Text nodes are slices of the source string JS already holds | +| Offsets | md4c's UTF-8 byte offsets to UTF-16 in `FlatBuffer.cpp`, the one conversion point (`OffsetParser.cpp` only builds the map) | Widens content ranges into construct spans (`widen.ts`) | +| Text | No source *slice* crosses: prose is offsets. Values that are not slices do, in the string table — hrefs, titles, info strings, decoded entities, the U+FFFD standing in for a NUL, and the text md4c synthesizes rather than points at: the `"\n"` it reports for every break and every code- or HTML-block line, which is the largest class, collapsed to one entry per consecutive run by `SaxState::intern` | Text nodes are sliced from the JS source except for those string-table values; escaped runs re-widened and smart punctuation applied here | | Policy | None | URL allowlist and HTML strip/raw, applied while the node is built | | Extensions | md4c flags for GFM, math and underline; never spoilers | Spoilers, opt-in, after the engine returns | -No source text crosses, so cost tracks structure rather than length. Decoding and policy stay in JavaScript because text values are slices of the JS string, and the allowlist has to run where the node is built so `parseDocument` can never return a rejected `href`. `entities.ts` and `urlPolicy.ts` sit at the engine root, not inside `native/`, because they describe the document model; a substituted engine can deep-import them instead of reimplementing the allowlist. Wire format and decoder details are in [NATIVE.md](NATIVE.md). +No source slice crosses, so cost tracks structure rather than length. What stays in JavaScript is escape re-widening, smart punctuation and policy: text values are slices of the JS string, and the allowlist has to run where the node is built so `nativeEngine` can never return a rejected `href` — the view checks a second time at the press, which is what extends the guarantee to a substituted engine. Entity decoding is not on this side: md4c ships the HTML5 table, so it decodes both prose entities and the ones inside an attribute, and `entities.ts` is left holding the decoder's defensive fallback. It and `urlPolicy.ts` sit at the engine root, not inside `native/`, because they describe the document model; `urlPolicy` is exported from the package entry so a substituted engine reuses the allowlist instead of reimplementing it. Wire format and decoder details are in [NATIVE.md](NATIVE.md). ### Headless use from Node -The package entry re-exports the view layer and so imports `react-native`, which plain Node cannot load. Import the deep paths instead: `dist/engine/Engine.js`, `dist/engine/options.js`, `dist/engine/native/index.js`, `dist/stream/StreamSession.js`, `dist/selection/*.js`, `dist/document/*.js`. Parsing still needs md4c, which under Node means the test addon in `native/node/`; see [NATIVE.md](NATIVE.md). A dedicated Node subpath export is a roadmap item. +The package entry re-exports the view layer and so imports `react-native`, which plain Node cannot load. Import the deep paths instead: `dist/engine/Engine.js`, `dist/engine/options.js`, `dist/engine/native/index.js`, `dist/stream/StreamSession.js`, `dist/selection/*.js`, `dist/document/*.js`. Parsing still needs md4c, which under Node means the test addon in `native/node/`; see [NATIVE.md](NATIVE.md). Those are declared subpath exports, not a reach past the package's front door: `package.json`'s `exports` map publishes `./dist/*` both with and without the `.js`, plus an explicit `./dist` entry for the bare directory. Each of those entries carries both conditions — `require` → `./dist/*.js` with types `./dist/*.d.ts`, `import` → `./dist/esm/*.js` with types `./dist/esm/*.d.ts`. The ES modules are the same sources emitted by `tsconfig.esm.json` (`module: es2020`) and finished by `scripts/finish-esm-build.mjs`, which writes `dist/esm/package.json` (`"type": "module"`, `sideEffects: false`) and adds the `.js` extension Node's ESM resolver needs on every relative specifier. They carry no stability promise. + +One caveat on the deep paths: two modules reach the platform through a call-expression `require` — `react-native` in `src/engine/native/install.ts`, the codegen spec in `src/view/RunHost.tsx` — and `require` does not exist in an ES module scope. That is why `require` is listed ahead of `import` there, so a resolver asserting both (Metro) stays on the CommonJS tree; both calls sit in a `try`/`catch` and degrade the way a web bundle already does. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index dac12ff..4572581 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -2,9 +2,19 @@ Two families of measurement: speed (is it fast, does it stay fast) and accuracy (is the output right, does streaming leave it unchanged). Accuracy -results gate `npm test`, which CI runs on every push and pull request. Speed -results are tracked as trends; the pathological suite becomes a gate when run -with a budget. +results gate `npm test`, which CI runs on every push and pull request. Most +speed results are tracked as trends; three benches are gates. The pathological +suite runs as `npm run bench:pathological -- --require-engine --budget-parse +750 --budget-repair 750 --budget-segment 200 --budget-project 750` in ci.yml's +test job and again in release.yml, and `npm run bench:projection -- +--require-engine` and a budgeted `bench:streaming` over the never-anchoring +transcript run beside it. The budgets are cliff detectors rather than +performance targets — anything that goes quadratic on those inputs lands in +seconds, not in the noise band — and the pathological ones are per stage +because the stages differ by orders of magnitude, so one number loose enough +for the table's parse would let `segment` get a hundred times slower and still +pass. The numbers themselves come from the runs recorded below, not from a CI +runner. There is one parser, md4c through the native module, and every number here is that parser. A machine that cannot build the Node addon measures nothing @@ -20,11 +30,22 @@ The Node benchmarks import compiled output, so build first: npm run build # emits dist/ (tsconfig.build.json) npm run bench:throughput # bench/throughput.mjs npm run bench:pathological # bench/pathological.mjs: report-only by default; - # pass a budget to make it a pass/fail gate: - # npm run bench:pathological -- --budget 2000 -npm run bench:streaming # bench/streaming-replay.mjs + # pass a budget to make it a pass/fail gate, which + # is how CI runs it: + # npm run bench:pathological -- --require-engine \ + # --budget-parse 750 --budget-repair 750 \ + # --budget-segment 200 --budget-project 750 +npm run bench:streaming # bench/streaming-replay.mjs: report-only by + # default; pass a budget to make it a gate, which + # is how CI runs it: + # npm run bench:streaming -- --transcript \ + # conformance/fixtures/transcript-giant-list.json \ + # --require-engine --repeat 1 \ + # --budget-chunk 20 --budget-finalize 50 npm run bench:crossing # bench/crossing.mjs: native parse vs JS decode -npm run bench:all # the four above, in that order +npm run bench:projection # bench/projection.mjs: projected characters per + # document character, cached vs full +npm run bench:all # the five above, in that order # Against other parsers. They are deliberately NOT dependencies of this # package, so point --libs at a directory where you installed them: @@ -34,15 +55,34 @@ npm run bench:headtohead -- --libs /tmp/mdbench # bench/head-to-head.mjs npm run conformance # → conformance/report-native.json npm test # jest: unit suites, the streaming prefix oracle, - # and the selection projection oracle + # the selection projection oracle, and the + # incremental-projection oracle ``` Flags: every timing bench takes `--quick` (one warmup, few iterations; good for checking a bench still runs, useless as a published number). `bench:throughput` and `bench:crossing` take `--iterations N` and -`--replicas R`. `bench:pathological` takes `--budget MS` and `--runs N`. +`--replicas R`. `bench:pathological` takes `--budget MS`, the per-stage +overrides `--budget-parse|-repair|-segment|-project MS` (the global is the +default for any stage without one), `--runs N` and `--require-engine`, which +turns an unresolvable addon from an exit-0 report into a failure. `bench:streaming` takes `--transcript PATH`, `--repeat N`, `--max-chunks N` -and `--replicas N`. `bench:headtohead` takes `--libs DIR`, `--replicas R`, +and `--replicas N`; it replays both pinned transcripts by default, and the +transcript flag narrows it to one. It gates as well as reports. `--budget MS` +is the default for both gated numbers: `--budget-chunk MS` bounds the p99 +append latency, `--budget-finalize MS` the single clean parse `finalize` does, +both per transcript. `--require-engine` turns an unresolvable addon, or a +`StreamSession` that cannot take one character, from an exit-0 report into a +failure. Without a budget nothing fails, because the absolute milliseconds +belong to the machine. A budgeted run that timed nothing (`--max-chunks 0`, +`--repeat 0`) exits 1 — a gate over nothing is a failure, the same rule +`bench:pathological` states. On the machine in Results below, the giant-list +transcript measures chunk p99 0.75–0.86 ms and finalize 0.59–0.85 ms (two runs, +2026-09-03), so the CI budgets sit ~25× and ~60× above them: cliff detectors, +not targets. `bench:projection` takes +`--transcript PATH`, `--chunk N` (delta size, default 18), `--require-engine` +and `--quick` (one document size instead of two — which measures no growth, so +it gates nothing). `bench:headtohead` takes `--libs DIR`, `--replicas R`, `--iterations N` and `--only `. The benches and the conformance runner refuse a stale `--engine` flag with a non-zero exit rather than printing md4c's numbers under a heading you did not choose. @@ -53,14 +93,22 @@ with no C++ toolchain the benches print a notice with the build command and exit 0, and `npm test` skips every suite that parses markdown. CI builds the addon as a hard gate so a job cannot go green having parsed nothing. -## Results (2026-09-01) +## Results (2026-09-02) Apple M2 Max (12-core, 64 GB), macOS 26.5, arm64 Node v22.12.0 running -natively (not under Rosetta), this repo at v0.10.0, addon built by -`scripts/build-node-addon.mjs` from the vendored md4c. One run of each command. -In the August 2026 runs on this machine, medians moved by up to ~20% between -back-to-back runs of the same command, so treat differences inside that band -as noise. +natively (not under Rosetta), this repo at v0.11.0 plus the unreleased audit +pass, addon rebuilt by `scripts/build-node-addon.mjs` from the vendored md4c. +One run of each command. In the August 2026 runs on this machine, medians moved +by up to ~20% between back-to-back runs of the same command, so treat +differences inside that band as noise. + +Everything below is from a 2026-09-02 re-run except two things. The +cross-parser comparison in the next section is still the 2026-09-01 run: the +other parsers are deliberately not dependencies of this package, so re-running +it needs `--libs `. And the streaming-replay rows and the +incremental-vs-full ratio were re-measured on 2026-09-03, on the same machine +and the same arm64 Node, because the bench changed which statistic it reports; +those rows say so. ### Markdown to HTML throughput @@ -93,8 +141,9 @@ re-encoding as failures. The August 2026 run measured this package at 11.37 MB/s on this corpus. This machine's default `node` is an x86_64 build running under Rosetta, and an -x86_64 run today lands in that lower range (10.5 MB/s on `bench:throughput`, -4.3 µs per append on `bench:crossing`), so compare like with like. +x86_64 run on 2026-09-01 landed in that lower range (10.5 MB/s on +`bench:throughput`, 4.3 µs per append on `bench:crossing`), so compare like +with like — every figure in this document is from the arm64 build. Full configuration matrix, same run: @@ -114,35 +163,60 @@ because the preset changes which constructs md4c looks for. ### Cost by document shape +The four `bench:pathological` rows are cold medians per pipeline stage — parse, +tail repair, run segmentation, run projection — not parse times. + | Benchmark | md4c, as shipped | | --- | --- | -| Cold parse, 96.4 kB corpus, AST only (`bench:throughput`) | 4.58 ms/parse mean (min 3.56, p95 6.35) → 21.1 MB/s mean, 27.1 MB/s best-of-run, 2772 blocks | -| Nested brackets, 20 kB (`bench:pathological`) | 0.47 ms | -| Alternating emphasis openers, 104 kB | 0.40 ms | -| 32 × 500 table, 113 kB | 10.3 ms (min 7.4, max 14.3 over 3 runs) | -| Deep blockquotes (1500 levels), 3 kB | 1.85 ms cold; 0.25 ms warm (`bench:crossing`) | -| Streaming replay, 131 chunks / 1.2 kB (`bench:streaming`) | 3.1 ms of append time (3.3 ms replay total), p50 0.02 ms/chunk, p99 0.18 ms | -| Streaming replay, 1055 chunks / 9.3 kB (`--replicas 8`) | 13.1 ms of append time (30.7 ms replay total), p50 0.01 ms/chunk, p99 0.05 ms | - -Three rows need a note: +| Full-document parse, 96.4 kB corpus, AST only, warm (`bench:throughput`) | 4.35 ms/parse mean (min 3.41, p95 5.46) → 22.2 MB/s mean, 28.3 MB/s best-of-run, 2772 blocks | +| Nested brackets, 20 kB (`bench:pathological`) | parse 0.46 · repair 2.01 · segment 0.02 · project 0.02 ms | +| Alternating emphasis openers, 104 kB | parse 0.53 · repair 7.04 · segment 0.08 · project 0.07 ms | +| 32 × 500 table, 113 kB | parse 10.52 · repair 0.38 · segment 2.89 · project 7.61 ms | +| Deep blockquotes (1500 levels), 3 kB | parse 1.57 · repair 0.09 · segment 0.56 · project 2.53 ms cold; 0.22 ms warm parse (`bench:crossing`) | +| Streaming replay, 131 chunks / 1.2 kB (`bench:streaming`, re-run 2026-09-03) | 2.52–2.80 ms of append time per replay (median of 3, three runs), p50 0.02 ms/chunk, p99 0.06–0.16 ms | +| Streaming replay, 1055 chunks / 9.3 kB (`--replicas 8`, re-run 2026-09-03) | 14.98 ms of append time (34.83 ms replay total), p50 0.01 ms/chunk, p99 0.09 ms | +| Streaming replay, 2484 chunks / 21.9 kB, one 420-item bullet list (re-run 2026-09-03) | p50 0.28 ms/chunk, mean parse input 10,854 chars — never anchors; 731 and 769 ms of append time in two runs (per-replay spread 715–945 ms) | +| Projection over the replayed transcript (`bench:projection`) | 6,045 characters projected for 1,162 of document (5.2×), worst single projection 274 | + +Five notes on the table: - **The many-cell table** is the most expensive shape per byte and the - noisiest: 10.3 ms for 113 kB, about twice what the same volume of prose - costs, with a 7.4 to 14.3 ms spread across three runs. 113 kB of `| cell |` - is 16,000 cells, each a cell node with a text child. Allocating them costs - more than finding them. + noisiest: 10.5 ms to parse 113 kB, about twice what the same volume of prose + costs, and 7.6 ms to project it. 113 kB of `| cell |` is 16,000 cells, each a + cell node with a text child. Allocating them costs more than finding them. - **Deep blockquotes** produce the fattest wire buffer: 3 kB of source, 72 kB of buffer (24×), because every nesting level is its own 24-byte enter/leave - event pair. The decode share is 44%, in line with the other shapes, so + event pair. The decode share is 49%, in line with the other shapes, so crossing cost tracks node count rather than bytes. The pathological bench's - 1.85 ms for the same input is a cold run with no warmup, on purpose: a - warmed-up JIT is not what a DoS attempt meets. -- **The incremental-vs-full ratio** is 0.435 on the 1.2 kB transcript - (3.08 ms streamed vs 131 full reparses at 0.05 ms each) and 0.065 at ×8 - (13.05 ms vs 1055 × 0.19 ms), falling as the document grows. The structural - number does not move: parse input per append is mean 107 / p95 254 / max - 277 characters at both sizes, and 11 of 130 appends (88 of 1047 at ×8) + 1.57 ms parse for the same input is a cold run with no warmup, on purpose: a + warmed-up JIT is not what a DoS attempt meets. It is also the one shape whose + cost is in the projection rather than the parse. +- **The two stages that dominate** — the repair on alternating emphasis + openers, the projection on the table — were invisible while this bench timed + only the parse. md4c is linear on all four shapes, so a parse-only gate + guarded the stage that was never going to fail. +- **The incremental-vs-full ratio** now compares matched statistics: both + sides are the median, across repeats, of ONE replay's total — the streamed + side summing its appends, the naive side summing the same number of full + reparses of the final document — after a matching untimed warmup, and the + bench prints both totals and their min–max spread. Re-measured 2026-09-03 on + this machine, `--repeat 3`, three runs: 0.666 / 0.664 / 0.647 on the 1.2 kB + transcript (streamed 2.78 / 2.80 / 2.52 ms against naive 4.17 / 4.22 / + 3.90 ms over 131 chunks) and 0.098 at ×8 (14.98 ms against 153 ms over 1055 + chunks of 9.3 kB), falling as the document grows. Still quote it as a band: + those historical runs printed a ratio spread of 0.447–0.913. Other runs can cross 1.0 at 1.2 kB; the recorded range is not a bound on future runs. The larger fixture shows a clearer benefit. The + structural numbers do not move: parse input per append is mean 105 / p95 254 + / max 277 characters at both sizes, and 11 of 130 appends (88 of 1047 at ×8) skipped the engine entirely. +- **The giant-list replay** is the same measurement on a shape that never + anchors, and it inverts: mean parse input 10,854 characters of a 21,927-char + document (max 21,881, so 99.8% of the whole thing), 2389 of 2389 appends + reached the engine, and 790 nodes lost identity per chunk. Those counts are + exact; the ratio is not, and it is above 1 in every run — 1.227 and 1.466 in + two runs on 2026-09-03 (streamed 731 and 769 ms against naive 596 and + 525 ms), i.e. slower than reparsing the document from scratch on every + token. A list offers no safe anchor, so nothing settles and nothing + downstream can be reused. ### Where a native parse spends its time (`bench:crossing`) @@ -151,25 +225,29 @@ buffer into the AST, (c) the whole `engine.parse` call. | Workload | (a) native parse + encode | (b) JS decode → AST | (c) total | (b) share | Wire buffer | | --- | --: | --: | --: | --: | --- | -| Append tail, 64 B (the streaming case) | 1.59 µs | 1.15 µs | 2.68 µs | 43% | 387 B (6.05× source), 13 events | -| Chat reply, 828 B | 7.05 µs | 5.19 µs | 11.92 µs | 43% | 1.7 kB (2.07× source), 65 events | -| Spec corpus ×4, 96.4 kB | 2.01 ms | 1.69 ms | 3.69 ms | 46% | 547.7 kB (5.68× source), 21,370 events | -| Deep blockquotes, 1500 levels, 3 kB | 141 µs | 111 µs | 251 µs | 44% | 72.2 kB (24.0× source), 3,005 events | -| Many-cell table, 32 × 500, 113.4 kB | 2.12 ms | 2.29 ms | 4.35 ms | 53% | 1.18 MB (10.4× source), 49,106 events | +| Append tail, 64 B (the streaming parse input) | 1.79 µs | 1.31 µs | 3.03 µs | 43% | 387 B (6.05× source), 13 events | +| Chat reply, 828 B | 6.62 µs | 4.08 µs | 10.65 µs | 38% | 1.7 kB (2.06× source), 65 events | +| Spec corpus ×4, 96.4 kB | 1.85 ms | 1.53 ms | 3.41 ms | 45% | 532.7 kB (5.52× source), 21,378 events | +| Deep blockquotes, 1500 levels, 3 kB | 127 µs | 109 µs | 223 µs | 49% | 72.2 kB (24.0× source), 3,005 events | +| Many-cell table, 32 × 500, 113.4 kB | 2.08 ms | 2.10 ms | 4.14 ms | 51% | 1.18 MB (10.4× source), 49,106 events | The last two rows are the pathological bench's shapes, built by the same generators in `bench/support.mjs`, timed with pinned iteration counts. The -bench prints its timer floor, 66 ns per timed region on this machine, and -never subtracts it; on the 64 B row that is ~4% of a stage. +bench prints its timer floor, 76 ns per timed region on this machine, and +never subtracts it; on the 64 B row that is 4–6% of a stage. Two takeaways: -- A streaming append costs ~2.7 µs end to end, ~0.02% of a 16.7 ms frame. The - remaining on-device cost is React and text layout, not parsing (speed - metrics 5 and 6, both planned). -- The decode is 43–53% of a parse by shape, and it is the half written in - JavaScript. The decoder reads the wire buffer at 324–337 MB/s on prose and - 514–649 MB/s on the node-dense shapes. Lazy per-block AST materialization +- `engine.parse` on a 64 B tail costs ~3.0 µs, ~0.02% of a 16.7 ms frame. That + is the crossing and nothing else: a whole `StreamSession.append` — anchor + scan, tail repair, parse, span shift, snapshot — averages 14 µs over the + 1055-chunk ×8 replay and 19–21 µs over the 131-chunk one (the streamed total + the streaming bench prints, divided by its chunk count, 2026-09-03), and the + mean parse input there is 105 characters rather than 64 B. The remaining on-device cost + is React and text layout, not parsing (speed metrics 5 and 6, both planned). +- The decode is 38–51% of a parse by shape, and it is the half written in + JavaScript. The decoder reads the wire buffer at 297–418 MB/s on prose and + 562–660 MB/s on the node-dense shapes. Lazy per-block AST materialization was considered as the next win and rejected (2026-08-25): the span-widening pipeline that eager block spans need is ~60% of decode, and renderers read every block, so lazy children would re-pay it (about 1.6× total) or need a @@ -179,11 +257,13 @@ Two takeaways: | Command | Result | | --- | --- | -| `npx jest` / `npx tsc --noEmit` | 31 suites, 792 tests, all green; typecheck clean | +| `npx jest` / `npx tsc --noEmit` | 46 suites, 1561 tests, all green; typecheck clean. Nothing asserts those two counts, so read them as of 2026-09-03 | | `npm run conformance` | 651/652 (99.85%) on CommonMark 0.31.2, 0 examples threw. Every section at 100% except HTML blocks (43/44); the single failure is example 174, `>
\n> foo\n\nbar`, where md4c ends the quoted HTML block differently from cmark. Per-section table: `conformance/report-native.json` | -| `npx jest src/engine/native` | 6 suites, 164 tests: ABI parity against the C++ headers, host-binding resolution, named-construct documents, span invariants over the whole spec corpus, `underline`, smart punctuation | -| `npm run bench:pathological -- --budget 2000` | all four adversarial cases pass, the slowest at ~10 ms against a 2000 ms budget | -| `npm run bench:streaming` | parse input per append stays flat at mean 107 / p95 254 / max 277 chars regardless of stream length; 11 of 130 appends (88 of 1047 at `--replicas 8`) skipped the engine entirely | +| `npx jest src/engine/native` | 6 suites, 211 tests: ABI parity against the C++ headers, host-binding resolution, named-construct documents, span invariants over the whole spec corpus, `underline`, smart punctuation | +| `npm run bench:pathological -- --require-engine --budget-parse 750 --budget-repair 750 --budget-segment 200 --budget-project 750` | all four adversarial cases pass in all four stages, the slowest at 10.5 ms (the table's parse) against its 750 ms budget and `segment` at 2.9 ms against 200 ms; the repair-scaling section reports 0.43× cost-per-bracket growth from n=500 to n=8000, well under its 2.00× limit. This is the step CI runs, and `--require-engine` is what stops it passing having measured nothing | +| `npm run bench:streaming` | on the sprint-review transcript, parse input per append stays flat at mean 105 / p95 254 / max 277 chars regardless of stream length, and 11 of 130 appends (88 of 1047 at `--replicas 8`) skipped the engine entirely; on the giant-list transcript nothing anchors, mean parse input is 10,854 of 21,927 chars and 0 appends skip the engine | +| `npm run bench:streaming -- --transcript conformance/fixtures/transcript-giant-list.json --require-engine --repeat 1 --budget-chunk 20 --budget-finalize 50` | gates: p99 append latency and finalize time on the never-anchoring transcript, the shape where every append re-reads the accumulated text. Measured on 2026-09-03: p99 0.86 ms against the 20 ms budget and finalize 0.59 ms against 50 ms (0.75 and 0.85 ms in a second run). ci.yml's test job and release.yml both run this exact step; `--require-engine` stops it passing having measured nothing, and a budgeted run that timed no chunk exits 1 | +| `npm run bench:projection -- --require-engine` | gates: projected characters per document character stay flat as the document doubles — 0.99× growth cached against 1.98× uncached, 5.2× amplification at either size against 32.6× and 64.6×. Cached growth above 1.25× per doubling exits 1, and ci.yml and release.yml run it. On the giant-list transcript both pipelines measure ~553× (growth 1.00× against 2.09×), because a list never anchors and nothing settles to reuse | ## Offset map and decoder pass (2026-08-25) @@ -212,13 +292,14 @@ decoder throughput are in the crossing table above. | # | Metric | Status | Where | What it reports | | --- | --- | --- | --- | --- | -| 1 | Cold-parse throughput | implemented | `bench/throughput.mjs` | MB/s mean and best-of-run over the spec corpus plus fixtures, replicated by `--replicas`, timed through `parseDocument`. Block count printed as a sanity check (2772 for ×4). Planned: per-corpus breakdown. | -| 2 | Pathological-input budget | implemented; gates with `--budget` | `bench/pathological.mjs` | Median cold time per adversarial case: 10k nested brackets, nested emphasis runs, 1500 nested blockquotes, a 32 × 500 table. With `--budget MS`, exceeding it or crashing exits 1. No warmup, so numbers run higher than the same input warm. | -| 3 | Streaming replay | implemented | `bench/streaming-replay.mjs` | ms/chunk at p50/p95/p99 and blocks whose identity changed per chunk (p50 7, p95 21, max 31, mean 8.5, identical at ×1 and ×8; churn is in the unsettled tail). Fixture: `conformance/fixtures/transcript-sprint-review.json`, hand-built deltas of 1–18 UTF-16 units with surrogate-safe splits. `--transcript PATH` replays any transcript of the same shape. | -| 4 | Incremental-vs-full reparse ratio | implemented | reported by `bench/streaming-replay.mjs` | Total streamed append time over `chunks × full reparse of the final document`, same engine both sides, plus parse-input size per append and the count of construct-free fast-path appends. Lower is better; falls as documents grow. Do not quote `--quick`. A stream that never anchors (one huge list, one giant paragraph, an unclosed fence) reparses its whole tail every chunk. | +| 1 | Full-document parse throughput (warm) | implemented | `bench/throughput.mjs` | MB/s mean and best-of-run over the spec corpus plus fixtures, replicated by `--replicas`, timed through `parseDocument` after 3 untimed warmups — warm, not cold; "cold" in this document means the no-warmup `bench:pathological`, and the deep-blockquote row shows the two differing ~7×. Block count printed as a sanity check (2772 for ×4). Planned: per-corpus breakdown. | +| 2 | Pathological-input budget | implemented; gates with `--budget`, and CI runs it | `bench/pathological.mjs` | Median cold time per adversarial case, per pipeline stage: parse (md4c + decode), repair (`repairTail` over the whole input, which is what a stream that never anchors hands it), segment (`segmentRuns`), project (`projectRun` over every run). Cases: 10k nested brackets, alternating emphasis openers, 1500 nested blockquotes, a 32 × 500 table. md4c is linear on all four, so a parse-only gate guarded the one stage that was never going to fail. `--budget MS` applies per stage, and `--budget-parse`, `--budget-repair`, `--budget-segment` and `--budget-project` override it for one stage (the global stays the default for the others) — which is how CI runs it, because a single budget loose enough for the table's parse leaves `segment`, four orders of magnitude cheaper, free to get a hundred times slower and still pass. Exceeding a budget, or throwing in any stage, exits 1 — a stack overflow on adversarial input is a denial of service whatever its runtime — and so does a gated run that produced no samples at all (`--runs 0`, or `--require-engine` meeting an unresolvable addon), because a gate over nothing is a failure, not a pass. No warmup, so numbers run higher than the same input warm. A second section, `repair scaling`, runs `repairTail` over `'x [ '.repeat(n)` for n in {500,1000,2000,4000,8000} against a same-length linear control and gates the growth in cost-per-bracket (flat ~1.00× is linear, ~16× would be quadratic, over 2.00× fails) — the four fixed-size cases cannot tell a slow linear pass from a fast quadratic one. 1500 is a budget case, not a limit: the JS pipeline is depth-safe at any depth (pinned at 20,000 levels across `src/stream/`, `src/selection/` and `src/view/`), and the only bound is `MAX_RENDER_DEPTH = 64` in `src/view/renderers.tsx`. | +| 3 | Streaming replay | implemented; gates with `--budget`, and CI runs it | `bench/streaming-replay.mjs` | ms/chunk at p50/p95/p99 and nodes (blocks and inlines) whose identity changed per chunk — the label the bench prints, and the count that predicts React re-render cost. On `transcript-sprint-review.json`: p50 7, p95 21, max 31, mean 8.3, identical at ×1 and ×8; churn is in the unsettled tail. On `transcript-giant-list.json`: mean 790. Both are replayed by default, in hand-built deltas of 1–18 UTF-16 units with surrogate-safe splits; `--transcript PATH` narrows to one, or replays any transcript of the same shape. What the budgets gate, per transcript, is the p99 append latency (`--budget-chunk`) and the single clean parse `finalize` does (`--budget-finalize`), with `--budget` as the default for both; ci.yml and release.yml run it over `transcript-giant-list.json`, the never-anchoring shape where every append re-reads the accumulated text (2484 chunks, 2389 of 2389 appends reaching the engine, mean parse input 10,854 chars). That is where a repair or splice pass that stops being linear shows up first, and it shows up in the number a user feels as jank. Without a budget nothing fails; a budgeted run that timed nothing exits 1. | +| 4 | Incremental-vs-full reparse ratio | implemented | reported by `bench/streaming-replay.mjs` | The median of one replay's streamed total against the median of one replay's naive total — the same number of full reparses of the final document, same engine, after a matching untimed warmup — plus both totals, their min–max spread, parse-input size per append and the count of construct-free fast-path appends. Lower is better; falls as documents grow. Both sides are now the same statistic (it used to divide a sum of chunk times by a median full parse × the chunk count), but the two distributions still overlap at 1.2 kB, so quote it as a band rather than a number and read the spread line with it. Do not quote `--quick`. A stream that never anchors reparses its whole tail every chunk: one huge list, one giant paragraph, an unclosed fence, or an unterminated HTML block of CommonMark type 1–5 (``, +`?>`, a declaration's `>`) cannot grow, so it anchors like anything +else — the same argument `closed: true` makes for a fenced code block. A +literal matching no start condition is one md4c did not start as type 1-4, so +a blank line really does end it and it anchors as types 6 and 7 do. +Keying on the opener alone cost every raw-HTML stream its anchor entirely: a +document of one-line `` blocks reparsed from offset 0 on every +append. None of the `htmlBlock` cases is reachable under the default +`html: 'strip'`, which emits no `htmlBlock` node at all. Every excluded block +freezes once an anchor-safe block after it is followed by a blank line. ### Tail-only reparse ``` -tailInput = repairTail(source.slice(anchor), cleanSeed, options).text -document = frozenPrefixBlocks ++ shiftSpans(parse(tailInput).blocks, anchor) +repaired = repairTail(source.slice(anchor), cleanSeed, options, repair, carry) +document = frozenPrefixBlocks ++ shiftSpans(parse(repaired.text).blocks, anchor) ``` `shiftSpans` (`src/stream/shiftSpans.ts`) deep-clones each tail block with -spans rebased by `anchor`; engine output is never mutated. Frozen prefix +spans rebased by `anchor`; engine output is never mutated. The clone walks +an explicit job stack rather than recursing, as do +`trimTrailingPlaceholders` and finalize's structure check, so nesting depth +costs heap rather than stack: a 40 kB `> ` prefix streams, freezes and +finalizes instead of throwing `RangeError` out of an append. Frozen prefix blocks are spliced back by reference, so settled identity holds by construction. With no anchor yet (one giant list, a single huge paragraph, an -unclosed fence), the tail is the whole source and the update is a correct -full reparse. +unclosed fence), the tail is the whole source, so the update is a correct +full reparse plus a full repair pass over the same text. The parse is +unavoidable; the repair's inline scan resumes from the previous append +(`carry`, below) instead of restarting, so only the new delta is scanned and +the pass measures about a third of the md4c parse beside it on a 32 kB +paragraph streamed in 18-character deltas. ### The construct-free fast path @@ -149,56 +326,166 @@ and `value += delta`, when: onto the raw source and reaches the end of the text; - the previous repair made zero changes; - the delta does not end in a space, a tab, or a lone high surrogate; -- the last line does not end in a bare-autolink candidate (`https:`, `www.`), - an HTML-block opener stub (`<`, ``/`
`/`