@flamingo-stack/openframe-frontend-core 0.0.508 → 0.0.509
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat-protocol/decode.d.ts +58 -0
- package/dist/chat-protocol/decode.d.ts.map +1 -0
- package/dist/chat-protocol/encode.d.ts +44 -0
- package/dist/chat-protocol/encode.d.ts.map +1 -0
- package/dist/chat-protocol/env-flag.d.ts +35 -0
- package/dist/chat-protocol/env-flag.d.ts.map +1 -0
- package/dist/chat-protocol/events.d.ts +216 -0
- package/dist/chat-protocol/events.d.ts.map +1 -0
- package/dist/chat-protocol/frames.d.ts +213 -0
- package/dist/chat-protocol/frames.d.ts.map +1 -0
- package/dist/chat-protocol/index.cjs +506 -0
- package/dist/chat-protocol/index.cjs.map +1 -0
- package/dist/chat-protocol/index.d.ts +18 -0
- package/dist/chat-protocol/index.d.ts.map +1 -0
- package/dist/chat-protocol/index.js +490 -0
- package/dist/chat-protocol/index.js.map +1 -0
- package/dist/chat-protocol/ip-normalize.d.ts +44 -0
- package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
- package/dist/chat-protocol/nats-decoder.d.ts +26 -0
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
- package/dist/{chunk-3SQ5KXHQ.cjs → chunk-2T4GTV25.cjs} +9 -9
- package/dist/{chunk-3SQ5KXHQ.cjs.map → chunk-2T4GTV25.cjs.map} +1 -1
- package/dist/{chunk-CWAOV2FK.cjs → chunk-3CGZPAGR.cjs} +3 -3
- package/dist/{chunk-CWAOV2FK.cjs.map → chunk-3CGZPAGR.cjs.map} +1 -1
- package/dist/{chunk-TLMJHMXJ.js → chunk-3QSR22IC.js} +12 -12
- package/dist/chunk-3QSR22IC.js.map +1 -0
- package/dist/{chunk-TWYNR4TZ.cjs → chunk-4G5TLHJD.cjs} +7141 -4799
- package/dist/chunk-4G5TLHJD.cjs.map +1 -0
- package/dist/{chunk-ADPMHWOE.js → chunk-72XAID7Y.js} +4 -4
- package/dist/{chunk-PAGKRNWK.js → chunk-7WZHBQ4J.js} +2 -2
- package/dist/{chunk-OCEKO5CW.js → chunk-AEELJC4N.js} +12106 -9764
- package/dist/chunk-AEELJC4N.js.map +1 -0
- package/dist/{chunk-Z42BGM6Q.cjs → chunk-BT2WWDFP.cjs} +5 -5
- package/dist/{chunk-Z42BGM6Q.cjs.map → chunk-BT2WWDFP.cjs.map} +1 -1
- package/dist/{chunk-7LEL3RIX.cjs → chunk-C7OY3PQP.cjs} +11 -11
- package/dist/{chunk-7LEL3RIX.cjs.map → chunk-C7OY3PQP.cjs.map} +1 -1
- package/dist/{chunk-RHGGEKPQ.cjs → chunk-CIOLOQKQ.cjs} +4 -4
- package/dist/{chunk-RHGGEKPQ.cjs.map → chunk-CIOLOQKQ.cjs.map} +1 -1
- package/dist/{chunk-TSZHM74B.js → chunk-DC2TKS7C.js} +2 -2
- package/dist/{chunk-M2GOR3XQ.cjs → chunk-DYPYR7FR.cjs} +87 -87
- package/dist/{chunk-M2GOR3XQ.cjs.map → chunk-DYPYR7FR.cjs.map} +1 -1
- package/dist/{chunk-I64ABCDX.js → chunk-I2B6X77L.js} +2 -2
- package/dist/{chunk-NIZDKTGL.cjs → chunk-K5MSCMDK.cjs} +37 -37
- package/dist/{chunk-NIZDKTGL.cjs.map → chunk-K5MSCMDK.cjs.map} +1 -1
- package/dist/{chunk-DTYRYB2N.js → chunk-K6QTZ7CZ.js} +2 -2
- package/dist/{chunk-4LTMXDUS.js → chunk-KBN7PMFT.js} +2 -2
- package/dist/{chunk-TBMV7I5N.js → chunk-KXZO2WZX.js} +2 -2
- package/dist/{chunk-QTYZMP6D.cjs → chunk-LBAKLT6T.cjs} +62 -62
- package/dist/chunk-LBAKLT6T.cjs.map +1 -0
- package/dist/{chunk-KEBYLU3U.js → chunk-LFUPI7VX.js} +2 -2
- package/dist/{chunk-LDTR4IVZ.cjs → chunk-M37PBG4N.cjs} +31 -31
- package/dist/{chunk-LDTR4IVZ.cjs.map → chunk-M37PBG4N.cjs.map} +1 -1
- package/dist/{chunk-WA7RR64F.js → chunk-PO5O654H.js} +6 -5
- package/dist/chunk-PO5O654H.js.map +1 -0
- package/dist/{chunk-WK4N5VBX.cjs → chunk-QJJISLPA.cjs} +28 -27
- package/dist/chunk-QJJISLPA.cjs.map +1 -0
- package/dist/{chunk-2F44PLTV.cjs → chunk-T7MCSA55.cjs} +26 -26
- package/dist/{chunk-2F44PLTV.cjs.map → chunk-T7MCSA55.cjs.map} +1 -1
- package/dist/{chunk-WJQHJD7J.js → chunk-TWSKTJHW.js} +2 -2
- package/dist/{chunk-ZSQHZYCO.cjs → chunk-UQDMJ6DH.cjs} +13 -13
- package/dist/{chunk-ZSQHZYCO.cjs.map → chunk-UQDMJ6DH.cjs.map} +1 -1
- package/dist/{chunk-TZRUCD56.js → chunk-V3ZAAFE3.js} +5 -5
- package/dist/{chunk-LRGHJPET.js → chunk-XGIFOHKE.js} +2 -2
- package/dist/{chunk-POOMO3PA.cjs → chunk-YQEPRYT5.cjs} +7 -7
- package/dist/{chunk-POOMO3PA.cjs.map → chunk-YQEPRYT5.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/hooks/index.d.ts +0 -1
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -57
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +10 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +21 -13
- package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
- package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts +380 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
- package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
- package/dist/components/chat/stream/index.d.ts +17 -0
- package/dist/components/chat/stream/index.d.ts.map +1 -0
- package/dist/components/chat/stream/message-mutations.d.ts +77 -0
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/types/api.types.d.ts +1 -81
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +2 -93
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +15 -0
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +2 -3
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +174 -132
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +65 -23
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +34 -34
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +44 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -2
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +55 -13
- package/dist/components/ui/markdown/base-components.d.ts +51 -0
- package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
- package/dist/components/ui/markdown/engine.d.ts +99 -0
- package/dist/components/ui/markdown/engine.d.ts.map +1 -0
- package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
- package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
- package/dist/components/ui/markdown/index.d.ts +18 -0
- package/dist/components/ui/markdown/index.d.ts.map +1 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
- package/dist/components/ui/markdown/sanitize.d.ts +153 -0
- package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/streaming.d.ts +78 -0
- package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
- package/dist/components/ui/markdown/text-size.d.ts +34 -0
- package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
- package/dist/index.cjs +60 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +71 -13
- package/dist/utils/index.cjs +169 -40
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +162 -41
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/markdown-fences.d.ts +42 -0
- package/dist/utils/markdown-fences.d.ts.map +1 -0
- package/dist/utils/markdown-heading-id.d.ts +85 -0
- package/dist/utils/markdown-heading-id.d.ts.map +1 -0
- package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
- package/package.json +7 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +319 -0
- package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
- package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
- package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +237 -0
- package/src/chat-protocol/decode.ts +278 -0
- package/src/chat-protocol/encode.ts +71 -0
- package/src/chat-protocol/env-flag.ts +39 -0
- package/src/chat-protocol/events.ts +254 -0
- package/src/chat-protocol/frames.ts +245 -0
- package/src/chat-protocol/index.ts +21 -0
- package/src/chat-protocol/ip-normalize.ts +146 -0
- package/src/chat-protocol/nats-decoder.ts +264 -0
- package/src/components/chat/.chat-message-list.md +10 -6
- package/src/components/chat/__tests__/chat-message-list.test.tsx +450 -9
- package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
- package/src/components/chat/__tests__/chat-pending-turn.test.tsx +250 -0
- package/src/components/chat/chat-message-enhanced.tsx +122 -20
- package/src/components/chat/chat-message-list.tsx +515 -133
- package/src/components/chat/embeddable-chat.tsx +6 -0
- package/src/components/chat/hooks/.index.md +30 -33
- package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
- package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
- package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
- package/src/components/chat/hooks/__tests__/chunk-catchup-dialog-staleness.test.ts +92 -0
- package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
- package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
- package/src/components/chat/hooks/index.ts +0 -1
- package/src/components/chat/hooks/use-chat.ts +8 -0
- package/src/components/chat/hooks/use-chunk-catchup.ts +26 -1
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +254 -825
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +380 -684
- package/src/components/chat/index.ts +5 -0
- package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +944 -0
- package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +806 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +467 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +1330 -0
- package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
- package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +105 -0
- package/src/components/chat/stream/chat-dialog-store.ts +536 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +1928 -0
- package/src/components/chat/stream/delta-batcher.ts +168 -0
- package/src/components/chat/stream/index.ts +55 -0
- package/src/components/chat/stream/message-mutations.ts +340 -0
- package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
- package/src/components/chat/thinking-display.tsx +1 -1
- package/src/components/chat/types/.api.types.md +39 -49
- package/src/components/chat/types/.processing.types.md +29 -58
- package/src/components/chat/types/.unified-chat-state.types.md +2 -2
- package/src/components/chat/types/api.types.ts +6 -73
- package/src/components/chat/types/processing.types.ts +11 -53
- package/src/components/chat/types/unified-chat-state.types.ts +16 -0
- package/src/components/chat/utils/.extract-incomplete-message-state.md +21 -3
- package/src/components/chat/utils/.history-merge.md +44 -3
- package/src/components/chat/utils/.index.md +31 -40
- package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
- package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
- package/src/components/chat/utils/__tests__/extract-incomplete-message-state.test.ts +106 -0
- package/src/components/chat/utils/__tests__/history-merge.test.ts +160 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +61 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +394 -0
- package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
- package/src/components/chat/utils/extract-incomplete-message-state.ts +93 -6
- package/src/components/chat/utils/history-merge.ts +85 -5
- package/src/components/chat/utils/index.ts +5 -8
- package/src/components/chat/utils/process-historical-messages.ts +361 -385
- package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
- package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
- package/src/components/shared/product-release/release-detail-page.tsx +19 -4
- package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
- package/src/components/ui/__tests__/markdown-parity.test.tsx +2540 -0
- package/src/components/ui/index.ts +1 -2
- package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +176 -0
- package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +151 -0
- package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +354 -0
- package/src/components/ui/markdown/__tests__/sanitize-render.test.tsx +118 -0
- package/src/components/ui/markdown/__tests__/streaming.test.tsx +436 -0
- package/src/components/ui/markdown/base-components.tsx +360 -0
- package/src/components/ui/markdown/engine.tsx +315 -0
- package/src/components/ui/markdown/heading-ids.ts +239 -0
- package/src/components/ui/markdown/index.ts +49 -0
- package/src/components/ui/markdown/mermaid-diagram.tsx +323 -0
- package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
- package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
- package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
- package/src/components/ui/markdown/sanitize.ts +3584 -0
- package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
- package/src/components/ui/markdown/streaming.ts +390 -0
- package/src/components/ui/markdown/text-size.ts +106 -0
- package/src/components/ui/release-changelog-section.tsx +1 -1
- package/src/components/ui/ticket-info-section.tsx +1 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/markdown-fences.ts +127 -0
- package/src/utils/markdown-heading-id.ts +348 -0
- package/src/utils/markdown-section-extractor.ts +41 -56
- package/dist/chunk-OCEKO5CW.js.map +0 -1
- package/dist/chunk-QTYZMP6D.cjs.map +0 -1
- package/dist/chunk-TLMJHMXJ.js.map +0 -1
- package/dist/chunk-TWYNR4TZ.cjs.map +0 -1
- package/dist/chunk-WA7RR64F.js.map +0 -1
- package/dist/chunk-WK4N5VBX.cjs.map +0 -1
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
- package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
- package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
- package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
- package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
- package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
- package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -489
- package/src/components/chat/utils/.chunk-parser.md +0 -62
- package/src/components/chat/utils/chunk-parser.ts +0 -262
- package/src/components/ui/.simple-markdown-renderer.md +0 -52
- package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
- package/src/components/ui/simple-markdown-renderer.tsx +0 -964
- /package/dist/{chunk-ADPMHWOE.js.map → chunk-72XAID7Y.js.map} +0 -0
- /package/dist/{chunk-PAGKRNWK.js.map → chunk-7WZHBQ4J.js.map} +0 -0
- /package/dist/{chunk-TSZHM74B.js.map → chunk-DC2TKS7C.js.map} +0 -0
- /package/dist/{chunk-I64ABCDX.js.map → chunk-I2B6X77L.js.map} +0 -0
- /package/dist/{chunk-DTYRYB2N.js.map → chunk-K6QTZ7CZ.js.map} +0 -0
- /package/dist/{chunk-4LTMXDUS.js.map → chunk-KBN7PMFT.js.map} +0 -0
- /package/dist/{chunk-TBMV7I5N.js.map → chunk-KXZO2WZX.js.map} +0 -0
- /package/dist/{chunk-KEBYLU3U.js.map → chunk-LFUPI7VX.js.map} +0 -0
- /package/dist/{chunk-WJQHJD7J.js.map → chunk-TWSKTJHW.js.map} +0 -0
- /package/dist/{chunk-TZRUCD56.js.map → chunk-V3ZAAFE3.js.map} +0 -0
- /package/dist/{chunk-LRGHJPET.js.map → chunk-XGIFOHKE.js.map} +0 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming-path tests for the unified markdown engine (plan §D1):
|
|
3
|
+
* atomic-block splitting, fence tail-completion, card-block cache
|
|
4
|
+
* exclusion, and — critically — that the COMPLETION render (streaming
|
|
5
|
+
* flipped off) equals the whole-document parse for every cross-block
|
|
6
|
+
* construct (late reference defs, loose-list numbering, footnote-ish
|
|
7
|
+
* splits, blockquote-internal blank lines, streamed tables).
|
|
8
|
+
*/
|
|
9
|
+
import React from 'react'
|
|
10
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
11
|
+
import type { Components } from 'react-markdown'
|
|
12
|
+
import { render, act } from '@testing-library/react'
|
|
13
|
+
import { splitStreamingBlocks, completeStreamingTail } from '../streaming'
|
|
14
|
+
import { MarkdownEngine } from '../engine'
|
|
15
|
+
|
|
16
|
+
vi.mock('mermaid', () => ({
|
|
17
|
+
default: {
|
|
18
|
+
initialize: vi.fn(),
|
|
19
|
+
render: vi.fn(async () => ({ svg: '<svg data-mock-mermaid="true"></svg>' })),
|
|
20
|
+
},
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
async function renderHtml(ui: React.ReactElement): Promise<string> {
|
|
24
|
+
let container: HTMLElement
|
|
25
|
+
await act(async () => {
|
|
26
|
+
const result = render(ui)
|
|
27
|
+
container = result.container
|
|
28
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
29
|
+
})
|
|
30
|
+
return container!.innerHTML
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('splitStreamingBlocks', () => {
|
|
34
|
+
it('splits plain paragraphs at blank lines', () => {
|
|
35
|
+
const blocks = splitStreamingBlocks('para one\n\npara two\n\npara three')
|
|
36
|
+
expect(blocks.map((b) => b.memoizable)).toEqual([true, true, false])
|
|
37
|
+
expect(blocks[0].text).toContain('para one')
|
|
38
|
+
expect(blocks[2].text).toContain('para three')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('never cuts inside a fenced code block', () => {
|
|
42
|
+
const blocks = splitStreamingBlocks('```js\ncode\n\nmore code\n```\n\nafter')
|
|
43
|
+
expect(blocks).toHaveLength(2)
|
|
44
|
+
expect(blocks[0].text).toContain('more code')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('never cuts a loose ordered list apart', () => {
|
|
48
|
+
const md = '1. first\n\n2. second\n\n3. third'
|
|
49
|
+
const blocks = splitStreamingBlocks(md)
|
|
50
|
+
expect(blocks).toHaveLength(1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('never cuts a multi-paragraph blockquote apart', () => {
|
|
54
|
+
const md = '> first para\n\n> second para'
|
|
55
|
+
expect(splitStreamingBlocks(md)).toHaveLength(1)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('does not cut before an indented continuation line', () => {
|
|
59
|
+
const md = '- item one\n\n continuation of item one\n\nnext para'
|
|
60
|
+
const blocks = splitStreamingBlocks(md)
|
|
61
|
+
expect(blocks[0].text).toContain('continuation')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('marks card:// and mention:// blocks non-memoizable (stale-card fix)', () => {
|
|
65
|
+
const blocks = splitStreamingBlocks(
|
|
66
|
+
'plain text block\n\nSee [card://blog:x] here\n\nCheck @device:r1 via mention\n\ntail',
|
|
67
|
+
)
|
|
68
|
+
expect(blocks.map((b) => b.memoizable)).toEqual([true, false, true, false])
|
|
69
|
+
// mention text without mention:// scheme stays memoizable; the scheme
|
|
70
|
+
// appears post-remark, so the splitter keys on the raw marker too:
|
|
71
|
+
const withScheme = splitStreamingBlocks('a [x](mention://device:r1) link\n\ntail')
|
|
72
|
+
expect(withScheme[0].memoizable).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('marks reference-definition/use and footnote blocks non-memoizable', () => {
|
|
76
|
+
const blocks = splitStreamingBlocks(
|
|
77
|
+
'See [text][ref] please\n\n[ref]: https://example.com\n\nA footnote[^1] here\n\ntail',
|
|
78
|
+
)
|
|
79
|
+
expect(blocks.map((b) => b.memoizable)).toEqual([false, false, false, false])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('keys blocks by position so identical blocks never alias', () => {
|
|
83
|
+
const blocks = splitStreamingBlocks('---\n\n---\n\nend')
|
|
84
|
+
expect(blocks.map((b) => b.index)).toEqual([0, 1, 2])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// --- fence-marker fidelity (reviewer-reported failures) ---
|
|
88
|
+
it('does not let ~~~ close a ``` fence', () => {
|
|
89
|
+
const md = '```text\nline\n~~~\n\nstill in code\n```\n\nafter'
|
|
90
|
+
const blocks = splitStreamingBlocks(md)
|
|
91
|
+
// The whole code block stays ONE unit; only the trailing prose splits.
|
|
92
|
+
expect(blocks).toHaveLength(2)
|
|
93
|
+
expect(blocks[0].text).toContain('still in code')
|
|
94
|
+
expect(blocks[1].text.trim()).toBe('after')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('does not let ``` close a ~~~ fence', () => {
|
|
98
|
+
const blocks = splitStreamingBlocks('~~~text\nline\n```\n\nstill in code\n~~~\n\nafter')
|
|
99
|
+
expect(blocks).toHaveLength(2)
|
|
100
|
+
expect(blocks[0].text).toContain('still in code')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('ignores fences inside 4-space-indented code (CommonMark caps indent at 3)', () => {
|
|
104
|
+
const blocks = splitStreamingBlocks(' ```\n not a fence\n\nreal para\n\ntail')
|
|
105
|
+
expect(blocks.length).toBeGreaterThan(1)
|
|
106
|
+
expect(blocks.some((b) => b.text.includes('real para'))).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('requires the closer to be at least as long as the opener', () => {
|
|
110
|
+
// The inner ``` is CONTENT of the ````-opened block.
|
|
111
|
+
const blocks = splitStreamingBlocks('````\n```\n\ninner\n````\n\nafter')
|
|
112
|
+
expect(blocks).toHaveLength(2)
|
|
113
|
+
expect(blocks[0].text).toContain('inner')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// --- raw HTML block integrity ---
|
|
117
|
+
it('never cuts inside an unbalanced raw HTML block', () => {
|
|
118
|
+
const blocks = splitStreamingBlocks(
|
|
119
|
+
'<details>\n<summary>More</summary>\n\nbody text\n\n</details>\n\ntail',
|
|
120
|
+
)
|
|
121
|
+
expect(blocks).toHaveLength(2)
|
|
122
|
+
expect(blocks[0].text).toContain('body text')
|
|
123
|
+
expect(blocks[0].text).toContain('</details>')
|
|
124
|
+
// Raw HTML units are never cached.
|
|
125
|
+
expect(blocks.every((b) => !b.memoizable)).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('never cuts inside a MULTI-LINE start tag', () => {
|
|
129
|
+
// `HTML_TAG_RE` needs `>` on the same line, so `<div\n class="x">` never
|
|
130
|
+
// opened the container: the splitter cut at every blank line and marked
|
|
131
|
+
// the fragments memoizable — the container's body escaped the div and
|
|
132
|
+
// the broken render got cached.
|
|
133
|
+
const blocks = splitStreamingBlocks('<div\n class="x">\n\nbody\n\n</div>\n\nafter')
|
|
134
|
+
expect(blocks).toHaveLength(2)
|
|
135
|
+
expect(blocks[0].text).toContain('body')
|
|
136
|
+
expect(blocks[0].text).toContain('</div>')
|
|
137
|
+
expect(blocks.every((b) => !b.memoizable)).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('releases the latch when a container closes MID-LINE', () => {
|
|
141
|
+
// A close tag not at column 0..3 (`some text </details>`) never
|
|
142
|
+
// decremented, so `htmlDepth` latched >0 and the whole rest of the
|
|
143
|
+
// message collapsed into ONE non-memoizable unit for the rest of the
|
|
144
|
+
// stream.
|
|
145
|
+
const blocks = splitStreamingBlocks(
|
|
146
|
+
'<details>\nstuff\n\nsome text </details>\n\nafter\n\nmore\n\ntail',
|
|
147
|
+
)
|
|
148
|
+
expect(blocks).toHaveLength(4)
|
|
149
|
+
expect(blocks[0].text).toContain('</details>')
|
|
150
|
+
expect(blocks[0].memoizable).toBe(false)
|
|
151
|
+
// Cutting resumed — the plain paragraphs after the container cache again.
|
|
152
|
+
expect(blocks.map((b) => b.memoizable)).toEqual([false, true, true, false])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('sees raw HTML indented to a list item content column', () => {
|
|
156
|
+
// The old column-0..3 gate missed this entirely, so the unit was marked
|
|
157
|
+
// memoizable despite containing raw HTML.
|
|
158
|
+
const blocks = splitStreamingBlocks(
|
|
159
|
+
'1. step\n\n <details>\n <summary>x</summary>\n </details>\n\ntail',
|
|
160
|
+
)
|
|
161
|
+
expect(blocks[0].text).toContain('<details>')
|
|
162
|
+
expect(blocks[0].memoizable).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('caps the latch so an unclosed container cannot disable cutting forever', () => {
|
|
166
|
+
const paras = Array.from({ length: 300 }, (_, i) => `para ${i}`).join('\n\n')
|
|
167
|
+
const blocks = splitStreamingBlocks(`<div>\n\n${paras}`)
|
|
168
|
+
// Past the cap the splitter resumes cutting…
|
|
169
|
+
expect(blocks.length).toBeGreaterThan(1)
|
|
170
|
+
// …but nothing inside the still-open container is ever cached.
|
|
171
|
+
expect(blocks.every((b) => !b.memoizable)).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('reports each unit’s document-wide startLine', () => {
|
|
175
|
+
const blocks = splitStreamingBlocks('one\n\ntwo\n\nthree')
|
|
176
|
+
expect(blocks.map((b) => b.startLine)).toEqual([1, 3, 5])
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('completeStreamingTail', () => {
|
|
181
|
+
it('closes an unterminated fence', () => {
|
|
182
|
+
expect(completeStreamingTail('text\n\n```js\nconst x =')).toMatch(/```$/)
|
|
183
|
+
})
|
|
184
|
+
it('leaves balanced fences alone', () => {
|
|
185
|
+
const md = '```js\ncode\n```'
|
|
186
|
+
expect(completeStreamingTail(md)).toBe(md)
|
|
187
|
+
})
|
|
188
|
+
it('does NOT touch stray emphasis/link openers (no false-positive close)', () => {
|
|
189
|
+
for (const md of ['2 * 3 is six', 'a lone _ under', 'an open [ bracket', 'tick ` alone']) {
|
|
190
|
+
expect(completeStreamingTail(md)).toBe(md)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('does not append a bogus fence when ``` and ~~~ both appear (closed block)', () => {
|
|
195
|
+
const md = '```\n~~~\n```'
|
|
196
|
+
expect(completeStreamingTail(md)).toBe(md)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('does not append a bogus fence to a ```-block containing a ~~~ line', () => {
|
|
200
|
+
const md = '```text\nline\n~~~\n\nstill in code\n```\n\nafter'
|
|
201
|
+
expect(completeStreamingTail(md)).toBe(md)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('closes an unterminated ~~~ fence with ~~~ (the RECORDED opener)', () => {
|
|
205
|
+
expect(completeStreamingTail('~~~py\nx =')).toBe('~~~py\nx =\n~~~')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('closes with a run at least as long as the opener', () => {
|
|
209
|
+
expect(completeStreamingTail('````\nstuff')).toBe('````\nstuff\n````')
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('MarkdownEngine streaming vs completion equivalence', () => {
|
|
214
|
+
const textOf = (html: string) =>
|
|
215
|
+
html
|
|
216
|
+
.replace(/<style[\s\S]*?<\/style>/, '')
|
|
217
|
+
.replace(/<[^>]+>/g, '')
|
|
218
|
+
.replace(/\s+/g, '')
|
|
219
|
+
|
|
220
|
+
const CASES: Record<string, { md: string; transientTextDivergence?: boolean }> = {
|
|
221
|
+
'loose list keeps numbering': { md: '1. first\n\n2. second\n\n3. third' },
|
|
222
|
+
// Reference links are the DOCUMENTED acceptable mid-stream transient:
|
|
223
|
+
// the use renders literally until the def arrives; completion self-heals.
|
|
224
|
+
'late reference definition resolves': {
|
|
225
|
+
md: 'See [the docs][ref] now.\n\n[ref]: https://example.com/late',
|
|
226
|
+
transientTextDivergence: true,
|
|
227
|
+
},
|
|
228
|
+
'blockquote with internal blank line': { md: '> first\n>\n> second' },
|
|
229
|
+
'gfm table': { md: '| A | B |\n|---|---|\n| 1 | 2 |' },
|
|
230
|
+
'multi-block document': { md: '# Title\n\npara one\n\n- a\n- b\n\npara two' },
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const [name, { md, transientTextDivergence }] of Object.entries(CASES)) {
|
|
234
|
+
it(`completion render equals whole-document parse: ${name}`, async () => {
|
|
235
|
+
const streamingHtml = await renderHtml(<MarkdownEngine content={md} streaming />)
|
|
236
|
+
const completedHtml = await renderHtml(<MarkdownEngine content={md} streaming={false} />)
|
|
237
|
+
const wholeHtml = await renderHtml(<MarkdownEngine content={md} />)
|
|
238
|
+
// Completion IS the authoritative whole-document parse — always.
|
|
239
|
+
expect(completedHtml).toBe(wholeHtml)
|
|
240
|
+
if (!transientTextDivergence) {
|
|
241
|
+
expect(textOf(streamingHtml)).toBe(textOf(wholeHtml))
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The `textOf` comparison above cannot see this class of divergence: when a
|
|
248
|
+
* list is cut apart, every remaining item becomes its own `<ol>`/`<ul>` with
|
|
249
|
+
* no `start`, so the TEXT is identical while the visible ordinals restart at
|
|
250
|
+
* "1." for every item. The assertion therefore has to be on list STRUCTURE.
|
|
251
|
+
*
|
|
252
|
+
* The shape is the single most common assistant answer — numbered steps whose
|
|
253
|
+
* items carry a code fence, a second paragraph, a table, or a nested list — and
|
|
254
|
+
* the broken units were `memoizable`, so the wrong render was CACHED for the
|
|
255
|
+
* rest of the turn rather than self-healing on the next token.
|
|
256
|
+
*/
|
|
257
|
+
const LIST_CONTINUATION_CASES: Record<string, string> = {
|
|
258
|
+
'ordered list with per-item code fences':
|
|
259
|
+
'1. Install:\n\n ```bash\n npm i\n ```\n\n2. Run it.\n\n3. Done.\n',
|
|
260
|
+
'ordered list with 4-space-indented fences':
|
|
261
|
+
'1. Install:\n\n ```bash\n npm i\n ```\n\n2. Run it.\n',
|
|
262
|
+
'list item with a second paragraph': '- item\n\n second para of item\n\n- item2\n',
|
|
263
|
+
'list item with a table continuation':
|
|
264
|
+
'1. Options:\n\n | a | b |\n |---|---|\n | 1 | 2 |\n\n2. Next step.\n',
|
|
265
|
+
'list item with a nested sublist': '- item\n - nested\n\n- item2\n',
|
|
266
|
+
'a list that follows an unrelated paragraph':
|
|
267
|
+
'- one\n\n- two\n\nplain para\n\n- three\n\n cont\n\n- four\n',
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const [name, md] of Object.entries(LIST_CONTINUATION_CASES)) {
|
|
271
|
+
it(`never cuts a list apart mid-stream: ${name}`, async () => {
|
|
272
|
+
const streamingHtml = await renderHtml(<MarkdownEngine content={md} streaming />)
|
|
273
|
+
const wholeHtml = await renderHtml(<MarkdownEngine content={md} />)
|
|
274
|
+
const listCount = (html: string) => (html.match(/<[ou]l[\s>]/g) ?? []).length
|
|
275
|
+
expect(listCount(streamingHtml)).toBe(listCount(wholeHtml))
|
|
276
|
+
expect(textOf(streamingHtml)).toBe(textOf(wholeHtml))
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
it('splits a list with a continuation block into ONE unit', () => {
|
|
281
|
+
const md = '1. Install:\n\n ```bash\n npm i\n ```\n\n2. Run it.\n\n3. Done.\n'
|
|
282
|
+
expect(splitStreamingBlocks(md)).toHaveLength(1)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('still cuts between a list and a genuinely separate following block', () => {
|
|
286
|
+
// The guard must not become "never cut once a list appeared" — a paragraph
|
|
287
|
+
// after a list is a real boundary and its unit must stay memoizable.
|
|
288
|
+
const blocks = splitStreamingBlocks('- one\n\n- two\n\nplain para\n\ntail para\n')
|
|
289
|
+
expect(blocks.length).toBeGreaterThan(1)
|
|
290
|
+
expect(blocks[0].text).toContain('- one')
|
|
291
|
+
expect(blocks[0].memoizable).toBe(true)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('streaming wrapper is aria-live polite', async () => {
|
|
295
|
+
const html = await renderHtml(<MarkdownEngine content={'hello'} streaming />)
|
|
296
|
+
expect(html).toContain('aria-live="polite"')
|
|
297
|
+
const done = await renderHtml(<MarkdownEngine content={'hello'} />)
|
|
298
|
+
expect(done).not.toContain('aria-live')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('raw HTML block renders identically mid-stream and completed', async () => {
|
|
302
|
+
const md = '<details>\n<summary>More</summary>\n\nbody text\n\n</details>\n\ntail'
|
|
303
|
+
const streamingHtml = await renderHtml(<MarkdownEngine content={md} streaming />)
|
|
304
|
+
const wholeHtml = await renderHtml(<MarkdownEngine content={md} />)
|
|
305
|
+
// Strip only the streaming aria-live wrapper; the disclosure widget and
|
|
306
|
+
// its body must be structurally identical (body INSIDE <details>, the
|
|
307
|
+
// closing tag not dropped).
|
|
308
|
+
const stripWrapper = (html: string) =>
|
|
309
|
+
html
|
|
310
|
+
.replace('<div aria-live="polite" aria-relevant="additions text">', '')
|
|
311
|
+
.replace('</div></article>', '</article>')
|
|
312
|
+
expect(streamingHtml).toContain('<details')
|
|
313
|
+
expect(streamingHtml).toContain('body text')
|
|
314
|
+
expect(stripWrapper(streamingHtml).replace(/\s+/g, '')).toBe(
|
|
315
|
+
stripWrapper(wholeHtml).replace(/\s+/g, ''),
|
|
316
|
+
)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('unterminated fence at the tail renders as code, not swallowed prose', async () => {
|
|
320
|
+
const html = await renderHtml(
|
|
321
|
+
<MarkdownEngine content={'intro\n\n```js\nconst partial ='} streaming />,
|
|
322
|
+
)
|
|
323
|
+
expect(html).toContain('code-block-container')
|
|
324
|
+
// hljs wraps tokens in spans — compare tag-stripped text.
|
|
325
|
+
expect(html.replace(/<[^>]+>/g, '')).toContain('const partial =')
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Atomic-block memo (regression: the memo never hit)
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
describe('streaming atomic-block memoization', () => {
|
|
333
|
+
/**
|
|
334
|
+
* Module-scope so the override map's IDENTITY is stable across rerenders —
|
|
335
|
+
* otherwise the test would be measuring its own instability instead of the
|
|
336
|
+
* engine's. The counter proves memo HITS: without it, DOM reconciliation
|
|
337
|
+
* reuses the same nodes either way, so node identity alone cannot fail.
|
|
338
|
+
*/
|
|
339
|
+
let paragraphRenders = 0
|
|
340
|
+
const COUNTING_OVERRIDES: Partial<Components> = {
|
|
341
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
342
|
+
p: ({ children }: any) => {
|
|
343
|
+
paragraphRenders++
|
|
344
|
+
return <p>{children}</p>
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
it('does NOT re-render completed blocks when the tail grows', async () => {
|
|
349
|
+
const base = 'alpha para\n\nbeta para\n\ngamma tail'
|
|
350
|
+
paragraphRenders = 0
|
|
351
|
+
|
|
352
|
+
let container!: HTMLElement
|
|
353
|
+
let rerender!: (ui: React.ReactElement) => void
|
|
354
|
+
await act(async () => {
|
|
355
|
+
const result = render(
|
|
356
|
+
<MarkdownEngine content={base} streaming componentOverrides={COUNTING_OVERRIDES} />,
|
|
357
|
+
)
|
|
358
|
+
container = result.container
|
|
359
|
+
rerender = result.rerender
|
|
360
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
const firstParagraph = container.querySelector('p')
|
|
364
|
+
expect(firstParagraph?.textContent).toBe('alpha para')
|
|
365
|
+
expect(paragraphRenders).toBe(3)
|
|
366
|
+
|
|
367
|
+
const before = paragraphRenders
|
|
368
|
+
await act(async () => {
|
|
369
|
+
rerender(
|
|
370
|
+
<MarkdownEngine
|
|
371
|
+
content={`${base} and more`}
|
|
372
|
+
streaming
|
|
373
|
+
componentOverrides={COUNTING_OVERRIDES}
|
|
374
|
+
/>,
|
|
375
|
+
)
|
|
376
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
// Only the live tail may re-parse. Before the NO_BROKEN_LINKS fix the
|
|
380
|
+
// `components` memo re-created every render, so all THREE re-rendered.
|
|
381
|
+
expect(paragraphRenders - before).toBe(1)
|
|
382
|
+
// …and the completed block's DOM node is the very same element.
|
|
383
|
+
expect(Object.is(container.querySelector('p'), firstParagraph)).toBe(true)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('survives heading ids: appending prose does not bust the block memo', async () => {
|
|
387
|
+
// The heading-id map is rebuilt on EVERY token (its input is the growing
|
|
388
|
+
// `processedContent`). It therefore reaches the heading renderers via
|
|
389
|
+
// CONTEXT, never through the `components` memo — otherwise its changing
|
|
390
|
+
// identity would re-create `components` per token and every completed
|
|
391
|
+
// block's `memo` would bail, exactly the regression above.
|
|
392
|
+
const base = '## Setup\n\nalpha para\n\nbeta para\n\ngamma tail'
|
|
393
|
+
paragraphRenders = 0
|
|
394
|
+
|
|
395
|
+
let container!: HTMLElement
|
|
396
|
+
let rerender!: (ui: React.ReactElement) => void
|
|
397
|
+
await act(async () => {
|
|
398
|
+
const result = render(
|
|
399
|
+
<MarkdownEngine content={base} streaming componentOverrides={COUNTING_OVERRIDES} />,
|
|
400
|
+
)
|
|
401
|
+
container = result.container
|
|
402
|
+
rerender = result.rerender
|
|
403
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
404
|
+
})
|
|
405
|
+
const firstParagraph = container.querySelector('p')
|
|
406
|
+
const before = paragraphRenders
|
|
407
|
+
|
|
408
|
+
await act(async () => {
|
|
409
|
+
rerender(
|
|
410
|
+
<MarkdownEngine
|
|
411
|
+
content={`${base} and more`}
|
|
412
|
+
streaming
|
|
413
|
+
componentOverrides={COUNTING_OVERRIDES}
|
|
414
|
+
/>,
|
|
415
|
+
)
|
|
416
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
417
|
+
})
|
|
418
|
+
expect(paragraphRenders - before).toBe(1)
|
|
419
|
+
expect(Object.is(container.querySelector('p'), firstParagraph)).toBe(true)
|
|
420
|
+
|
|
421
|
+
// A tail that DOES add a heading still gets correct, unique ids.
|
|
422
|
+
await act(async () => {
|
|
423
|
+
rerender(
|
|
424
|
+
<MarkdownEngine
|
|
425
|
+
content={`${base}\n\n## Setup\n\nsecond`}
|
|
426
|
+
streaming
|
|
427
|
+
componentOverrides={COUNTING_OVERRIDES}
|
|
428
|
+
/>,
|
|
429
|
+
)
|
|
430
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
431
|
+
})
|
|
432
|
+
const ids = Array.from(container.querySelectorAll('h2')).map((el) => el.id)
|
|
433
|
+
expect(ids).toEqual(['setup', 'setup-2'])
|
|
434
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
435
|
+
})
|
|
436
|
+
})
|