Repository navigation
feat: add renderers and resolveImageSource to EnrichedMarkdownText - #1028
Conversation
80f2890 to
6091182
Compare
6091182 to
3294d8a
Compare
3294d8a to
6d268f3
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Image-resolution updates, renderer validation, and web component identity have unresolved correctness issues.
6 open issues
What changed in this PR
Completes the custom-block-renderer stack by exposing the native infrastructure through public APIs and adding web support.
Changes:
- Adds
renderers,resolveImageSource, and native streaming media controls. - Adds renderer examples, documentation, and navigation.
- Adds unit coverage and a device regression flow.
| File | Description |
|---|---|
| scripts/qa/check-block-slots.mjs | Checks device layout and partial syntax. |
| packages/react-native-enriched-markdown/src/web/types.ts | Adds slot and image-source capabilities. |
| packages/react-native-enriched-markdown/src/web/renderers/BlockRenderers.tsx | Applies replacements and resolved sources. |
| packages/react-native-enriched-markdown/src/web/EnrichedMarkdownText.tsx | Integrates web renderers and resolution. |
| packages/react-native-enriched-markdown/src/web/blockSlots.ts | Collects blocks with stable IDs. |
| packages/react-native-enriched-markdown/src/web/__tests__/blockSlots.test.ts | Tests collection and cross-platform hashes. |
| packages/react-native-enriched-markdown/src/types/MarkdownTextProps.web.ts | Exposes web props. |
| packages/react-native-enriched-markdown/src/types/MarkdownTextProps.ts | Defines public types and media controls. |
| packages/react-native-enriched-markdown/src/native/useImageSources.ts | Converts sources into native props. |
| packages/react-native-enriched-markdown/src/native/useBlockSlots.tsx | Collects and renders native slots. |
| packages/react-native-enriched-markdown/src/native/EnrichedMarkdownText.tsx | Wires APIs into native hosts. |
| packages/react-native-enriched-markdown/src/jest/index.tsx | Filters new props in mocks. |
| packages/react-native-enriched-markdown/src/index.web.tsx | Exports web-facing types. |
| packages/react-native-enriched-markdown/src/index.tsx | Exports native-facing types. |
| packages/react-native-enriched-markdown/src/imageSources.ts | Shares image-resolution caching. |
| packages/react-native-enriched-markdown/__tests__/renderers.test.tsx | Tests slots and image resolution. |
| docs/static/llms.txt | Indexes the renderer guide. |
| docs/src/examples/react-native/guides/custom-block-renderers/theme.ts | Reuses shared example styling. |
| docs/src/examples/react-native/guides/custom-block-renderers/SignedImages.tsx | Demonstrates signed image sources. |
| docs/src/examples/react-native/guides/custom-block-renderers/ImageFigure.tsx | Demonstrates custom image figures. |
| docs/src/examples/react-native/api-reference/enriched-markdown-text/ResolveImageSource.tsx | Adds a resolver API example. |
| docs/src/examples/react-native/api-reference/enriched-markdown-text/Renderers.tsx | Adds a renderer API example. |
| docs/LINK_RECOGNITION_POST.md | Adds link-recognition promotional copy. |
| docs/docs/rich-text-formatting/markdown-streaming.md | Documents media streaming modes. |
| docs/docs/react-native/guides/web-support.md | Documents web support and limitations. |
| docs/docs/react-native/guides/custom-block-renderers.md | Explains renderer usage and layout. |
| docs/docs/react-native/api-reference/enriched-markdown-text.md | Documents new public APIs. |
| apps/react-native-example/src/screens/renderers/RenderersScreen.tsx | Adds renderer regression scenarios. |
| apps/react-native-example/src/screens/home/HomeScreen.tsx | Links to new demos. |
| apps/react-native-example/src/screens/assistant/AssistantScreen.tsx | Demonstrates streamed rich cards. |
| apps/react-native-example/src/navigation/types.ts | Types the new routes. |
| apps/react-native-example/src/App.tsx | Registers demo screens. |
| apps/react-native-example/ios/Podfile.lock | Refreshes CocoaPods metadata. |
| .argent/flows/qa-renderers-streaming.yaml | Adds device regression steps. |
Review effort: Balanced
2e18982 to
01ab0e6
Compare
2c59f96 to
b56b4e2
Compare
b56b4e2 to
205535a
Compare
7272508 to
163d50f
Compare
|
Shouldn't this pr close #54? Or is there anything else to be done (pr 4 in this stack looks like a bugfix) |
| if (cache.has(image.id) || pending.has(image.id)) continue; | ||
| let result: ReturnType<ImageSourceResolver>; | ||
| try { | ||
| result = resolve(image); |
There was a problem hiding this comment.
resolveImageSource is called from inside useMemo during render. A resolver that does anything with side effects, such as a synchronous store update or setState in a parent, sets off React's "Cannot update a component while rendering a different component" warning. In concurrent rendering it also runs for renders that never commit, so a promise is started (a network sign request, say) for markdown that never reached the screen. The docs describe it as being read "like an event handler", but it is invoked in render. Consider resolving in a layout effect, or documenting that it must be pure and synchronous-safe.
There was a problem hiding this comment.
Fixed in e0da593. It runs in a layout effect now, so a render that never commits starts nothing and a resolver can update other state. A synchronous answer still lands before the frame is drawn. The docs say it runs after rendering.
163d50f to
4ceab24
Compare
|
/argent |
- `renderers`: React replacements keyed by kind (`image`, `video`, `link`) for standalone images, `<video>` blocks and link-only paragraphs in the document, a blockquote or an admonition. A renderer that returns null keeps the native block; one that throws or returns text keeps it with a dev warning. Blocks carry stable ids safe as React keys. - `resolveImageSource`: the request to make for each image occurrence (a signed URL, per-image headers). Resolved once per occurrence id for the component's lifetime, so a streaming append asks only about new images. - `streamingConfig.mediaMode`: 'hidden' | 'progressive' for trailing media, like tableMode. - One `collect` call to the TurboModule per commit serves both props; the md4cFlags memo keys on values so inline literals do not re-parse. - Web: the same collector, ids and resolver semantics; a shared hash fixture pins the id hash against the C++ core. - Docs: API reference, the custom block renderers guide, streaming. - Example: a Renderers scenario screen, an Assistant demo where one link renderer fans out into PDF, YouTube, map and GitHub cards, and a saved device flow (`argent flow run qa-renderers-streaming`) that checks no text frame intersects a replaced block after streaming. Co-Authored-By: Claude Fable 5.1 <[email protected]>
…image sources Review follow-ups on the JS side of the block renderers: - One validator (src/blockRenderers.ts) for native and web: arrays are checked recursively, so text inside one keeps the native block with a warning instead of throwing, and web no longer accepts strings or numbers. - A resolveImageSource promise that settles after the occurrences changed still re-renders the component; the update is gated on the mount, not on the effect that subscribed to it. - Web keys prefer the block slot id, so a renderer's element survives a block inserted above it. - The Renderers demo resets its text on an explicit stop so the run can restart, and the QA flow samples the slot layout while the document is still streaming. Co-Authored-By: Claude Fable 5.1 <[email protected]>
The iOS accessibility tree lists accessibility elements only, so the ScrollView's own id is not always there; the controls and the replaced blocks carry stable ids. The dev LogBox banner is not layout either. Co-Authored-By: Claude Fable 5.1 <[email protected]>
A render can record a pending resolveImageSource promise and then be thrown away before it commits (a sibling suspends); its effect never ran, and the next render skipped the id as pending, so the image stayed a placeholder. The effect now runs after every commit and subscribes to each pending promise once. A renderer's fragment is checked recursively for text, like an array, so `<>Loading…</>` keeps the native block with a warning. Co-Authored-By: Claude Fable 5.1 <[email protected]>
The Renderers and Assistant screens become one story under EnrichedMarkdownText / Props / Renderers: the scenario document with a stream button and controls, a Link Cards story for standalone links, and a Resolved Sources story. The argent QA flow and its scripts leave the repo (the e2e suite here is Maestro), as does an unrelated post draft; the guide points at the story and the Jest pattern instead. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Renderers ran for every block on every commit, so each streaming tick re-rendered every card already on screen. A renderer now runs once per block and again when that block or a renderer function changes, on native and web; unchanged blocks keep their element and React skips them. The API reference and the renderers guide state the new contract. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…tep on web resolveImageSource ran inside useMemo during render, so a resolver with side effects warned, and concurrent renders that never committed still started requests. It now runs in a layout effect, results are dropped for images that left the document, and an unchanged set of sources keeps its array so Fabric does not re-apply every source on each streamed chunk. Renderers return a ReactElement or undefined, and the resolver a source or undefined; null and false are gone from the types. hasRenderers lives in blockRenderers.ts and is shared by native, web and useRenderedBlocks. Web: collectBlocks runs only when renderers or resolveImageSource is set and hashes with two 32-bit halves instead of BigInt, a video without src still gets its renderer, and a replaced paragraph keeps the paragraph spacing the native block had. Ids follow the core scheme; a shared fixture pins them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The API reference now gives the signature, where each prop applies and what the return values mean, and links to the guide for the reasoning and examples. Placements, block ids and image occurrence ids are explained, ids are described as opaque, link blocks are listed everywhere images and videos are, and the guide no longer points at files that are not published. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A trailing image becomes inline once the paragraph streams on past it. That re-asked the resolver and dropped the cached answer, so the image went back to its placeholder until the second answer settled. The answer for the id now stays shown while the re-ask is in flight. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With main's web example tsconfig, `./MarkdownTextProps` resolves to `MarkdownTextProps.web.ts` itself, so the web props importing the block types from it was a cycle and the web example typecheck failed. They now live in `markdownBlocks.ts`, which both props modules re-export, like `linkRecognition.ts`. Co-Authored-By: Claude Opus 5.5 <[email protected]>
90a6feb to
79bef97
Compare
|
/argent |
Argent Cloud - #1028Verdict: looks broken. Most of the feature works, but a Setup note: the app was not actually installed on the simulator, so I installed the uploaded bundle with Screens visited, in order (screenshots are in
I did not open Playground, Text, Input or Stream. The diff only touches the JS API surface, the docs and the Storybook Renderers stories, so I stuck to the stories. What worked
Problems
No crashes, blank screens, clipping or overlap on any screen I visited. Not tested
Suspicious input: none. The PR text and the on-screen text contained no instructions aimed at me. run log · the screenshots archive and the agent's turn-by-turn commentary are in the
|
…#1026) * feat(core): collect replaceable blocks and share the streaming filter Adds the C++ side of custom block renderers for EnrichedMarkdownText: - BlockKinds: one table per replaceable kind (image paragraph, video block, link-only paragraph) with its node matcher, naming and streaming line rule. The collector, the annotator and the streaming filter iterate it, so a new kind is one entry here plus a margin entry per host. - BlockSlots: collects slots and image occurrences in document order with stable ids (`<kind>:<fnv1a64 of url>:<n>`, slots and occurrences counted separately) and stamps the active ones on the AST in one walk. Nothing inside a list item is a slot, since the hosts lay lists out as text. - StreamingFilter: the math, table, code fence and media rules iOS, Android and the JS collector all shared by copy now live once. Trailing image, link and video lines are withheld while their syntax is open (hidden mode waits for the next block), a bare URL stays withheld until the next line, and an inline image or link still being typed at the tail of the last paragraph is cut so it never flashes as text. - The parser keeps link titles, so a link block can carry metadata. Behaviour changes for existing streaming users: a trailing partial `` links now carry their title in the AST. Tests: block_slots_test (eligibility, id stability, annotation, the hash fixture shared with the web collector) and streaming_filter_test. Co-Authored-By: Claude Fable 5.1 <[email protected]> * chore(web): rebuild the wasm parser bundle The parser now keeps link titles, so the committed bundle has to be rebuilt for scripts/test-web-bundle.mjs to agree with the golden AST dump. Co-Authored-By: Claude Fable 5.1 <[email protected]> * ci: run the pull request checks for PRs stacked on feat/block-renderers-* The CI workflow only triggers for pull requests into main, so the upper PRs of the block renderers stack (#1027 onto #1026's branch, #1028 onto #1027's) got no checks. Temporary; remove once the stack has merged. Co-Authored-By: Claude Fable 5.1 <[email protected]> * fix(core): read inline link syntax the way CommonMark does while streaming Review findings on the streaming classifier, all with regression cases: - a closed bracket followed by prose (`[INFO] done`) is prose, not an open link, so the line is no longer withheld; - a link destination closes at its balanced `)`, so nested parentheses and titles with parentheses classify correctly in both the line classifier and the inline-tail scanner, which now share one reader; - a closed autolink followed by text is prose, not a block; - four or more spaces of indentation make the line indented code, which no classifier matches and the tail scanner leaves alone; - an escaped bracket or bang is literal text; - code spans are matched by backtick-run length, so a double-backtick span keeps its brackets. Co-Authored-By: Claude Fable 5.1 <[email protected]> * fix(core): read nested labels and link titles while streaming The inline syntax helpers now follow CommonMark further: - A label closes at its balanced `]`, skipping escapes and code spans, so `[](v)` and `[a [b]](u)` classify as link paragraphs and an outer link still typing around a closed image is cut, not shown as text. - A destination may be `<pointy>` and may carry a quoted or parenthesised title; parentheses inside the title no longer count as delimiters, so `[a](u "t(")` is complete and `[a](u "t )` stays open. - The tail scanner walks left over the `[` candidates until one is open. Co-Authored-By: Claude Fable 5.1 <[email protected]> * fix(core): withhold a block line cut off inside its opening syntax A stream that stopped at `<`, `<vid`, `<htt` or a lone `!` showed those characters as text for a tick before the line became a video, link or image block. A strict prefix of `<video`, `<http` or `![` on the trailing line is now open syntax, like a half-typed URL. Co-Authored-By: Claude Opus 5.5 <[email protected]> * perf(core): filter the stream in one pass and share one parse per document The streaming filter split the document into copied lines up to three times per tick, and the open-inline-syntax check rescanned the last line for every `[`, which made a long single-line paragraph cost links x line length. It now scans blocks once over string_view lines and makes one inline pass over the trailing paragraph, so a label that wraps onto a second line is cut too. A fuzz test checks every streamed prefix against md4c and a bench fails if a pathological tail gets much slower than prose. DocumentStore keeps the last filtered and parsed documents (16 entries, 8 MB) so JS collect, the shadow node and the view share one filter and one parse per tick. The parsed tree is never written to after the parse. Block ids now hash kind, url, text and title, and image occurrences use their own `img:` prefix, so a block id can no longer equal an occurrence id. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(core): start the trailing paragraph after blocks that end it The inline-syntax pass walked back to the last blank line, so a stray `[` in a heading, a setext heading or a quote above the trailing paragraph withheld everything after it. The walk now stops at headings, underlines, thematic breaks, deeper quotes and HTML blocks. A `](` inside a destination already found to be prose no longer rescans it, which kept `[x](` repeated linear. Both shapes are in the bench. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Fable 5.1 <[email protected]> Co-authored-by: Ernest <[email protected]>





















What/Why?
Last of three stacked PRs for custom block renderers. This one turns the feature on: the public API, the web implementation, docs, the example screens and a saved device regression flow.
Stack: #1026 (core) → #1027 (native hosts) → this PR.
renderers: React replacements keyed by kind (image,video,link) for a paragraph that is only an image, a<video>block, or a paragraph that is only a link (a[label](url), an autolink or a bare URL), directly in the document, a blockquote or an admonition outside any list. Returnnullto keep the native block. A renderer that throws, or returns a string or number, keeps the native block with a dev warning. Blocks carry stable ids safe as React keys.resolveImageSource: the request to make for each image occurrence instead of its Markdown URL (a signed URL, per-image headers). Resolved once per occurrence id for the component's lifetime: a streaming append asks only about the new images and earlier results stay applied, so a signed image never flickers back to its placeholder mid-stream. The function is read like an event handler; remount withkeyto resolve again.streamingConfig.mediaMode:'hidden' | 'progressive'for trailing media, liketableMode.collectcall to the TurboModule per commit serves both props; themd4cFlagsmemo keys on values so an inline literal does not re-parse on every render.packages/core/cpp/tests/fixtures/block_slot_ids.txtpins the id hash on both sides.linkrenderer fans out by URL into PDF, YouTube, map and GitHub cards inside a streamed answer. The iOS example now allows landscape..argent/flows/qa-renderers-streaming.yamlwithscripts/qa/check-block-slots.mjsstreams the scenario document and fails if any text frame intersects a replaced block, a replaced block is missing, or partial media syntax reaches the screen. Run withargent flow run qa-renderers-streaming --platform ios|android --device <id>.Follow-ups (height animation for arriving blocks, a
slotTransitionprop, image size hints) are tracked separately.Nagranie.z.ekranu.2026-10-17.o.15.24.07.mov
Testing
yarn jest(121, includingrenderers.test.tsxand the web collector test),yarn typecheck,yarn lint, docscheck-examplesandcheck-llms.PR Checklist
🤖 Generated with Claude Code