diff --git a/CHANGELOG.md b/CHANGELOG.md index c1536329..444e237f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,13 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim ## [Unreleased] +### Fixed +- Added the selected standalone Markdown or HTML value to an explicitly configured SSR native form field, preserving controlled-value precedence, external form association, React attribute escaping, and the synchronous post-hydration TipTap transaction mirror + ### Security - Added a fail-closed draft release asset inventory gate that requires exactly one npm tarball, one Office wheel, and `SHA256SUMS`, rejects stale or unexpected draft assets before immutable publication, and verifies every GitHub-reported `sha256:` asset digest against the transferred local file +- Kept SSR document disclosure opt-in through `formFieldName`; hidden-field values remain client-controlled submission data and do not replace host authentication, authorization, tenant isolation, CSRF defenses, server validation, durable concurrency, or persistence controls +- Kept collaborative Yjs document content out of server markup until the host-owned client collaboration lifecycle is bound ### Added - Added one optional construction-time `onSnapshotChange` callback to the framework-free autosave queue and durable autosave session so hosts can observe saving, pending, blocked, recovery, idle, and shutdown state without polling or introducing a subscriber collection @@ -23,6 +28,10 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim - Preserved buyer-facing README guidance within the same validated safe-link command boundary and moved the shortcut-specific behavior contract to the authoritative accessibility and doctoring records so it is not misattributed to Tiptap - Added deterministic regression and documentation contracts plus APA 7th doctoring for exact `Control`/`Meta` shortcut alternatives, the descriptive-only accessibility boundary, repository-level shortcut verification, and omission of unsupported shortcut claims +### Tests +- Added test-first Node `renderToString` evidence for the missing SSR native value, controlled-over-default selection, escaping, external form ownership, no ProseMirror server construction, and opt-out non-disclosure +- Added browser-DOM handoff tests proving the field retains and updates the selected value before TipTap exists while reset-only unnamed fields remain empty + ### Documentation - Added a repository-native security disclosure and vulnerability-handling policy with supported pre-1.0 release lines, private GitHub Security Advisory routing and safe public fallback, minimized evidence guidance, explicit Inkspan-versus-host ownership boundaries, no-SLA and non-conformance claim limits, deterministic documentation tests, and APA 7th doctoring grounded in current ISO/IEC 29147:2018, ISO/IEC 30111:2019, final NIST SP 800-218 SSDF 1.1, the draft-status boundary for SSDF 1.2, and GitHub primary documentation - Added an authoritative standalone and modular MSA architecture contract with reviewable deployment, optimistic-concurrency, data-ownership, security, and acquisition-evidence diagrams and tables @@ -32,6 +41,7 @@ Historical release entries from **0.1.0 through 0.5.27** are preserved verbatim - Added stale-generation conflict recovery and operational save failure recovery through one reason-aware single-flight host workflow, so newer local edits cannot hide or duplicate recovery while retained work remains blocked; rejected or malformed resume attempts retain the same recovery surface until a valid resume succeeds or the editing context is disposed - Added exact-head read-only CI with fixed Ubuntu 24.04 runners, immutable action pins, explicit contributor-head checkout, disabled persisted Git credentials, and a documented merge-result compatibility boundary - Added deterministic documentation contract tests and APA 7th doctoring grounded in RFC 9110, WCAG 2.2, NIST SP 800-204, NIST SP 800-204D, OWASP ASVS 5.0.0, React, current Next.js App Router guidance, and GitHub Actions primary documentation +- Added APA 7th doctoring for the SSR native form field, including the WHATWG hidden-input/form-entry contract, React server/hydration continuity, client-controlled-data boundary, host-owned CSRF and acceptance controls, collaboration exclusion, and rollback - Added lifecycle-observation doctoring covering bounded callback retention, local-versus-shareable evidence, durable-validator coherence, WCAG 2.2 status-message responsibilities, rollback, and APA 7 references to RFC 9110, WCAG 2.2, and optimistic concurrency research ## [0.5.29] — 2026-08-05 diff --git a/docs/doctoring/ssr-native-form-serialization.md b/docs/doctoring/ssr-native-form-serialization.md new file mode 100644 index 00000000..54d6eb5c --- /dev/null +++ b/docs/doctoring/ssr-native-form-serialization.md @@ -0,0 +1,151 @@ +# SSR native form serialization + +## Decision + +When a standalone `CwlEditor` host explicitly supplies `formFieldName`, Inkspan +renders the selected Markdown or HTML prop value into the native hidden form +field in the server-generated shell. A controlled `value` takes precedence over +`defaultValue`, matching the document source selected for initial TipTap +construction. React owns attribute escaping, so source HTML is serialized as an +ordinary form-control value rather than injected as markup. + +The field is rendered as a `readOnly` controlled React input. Before TipTap is +authoritative, its controlled value is the selected server/client prop value. +After TipTap becomes authoritative, the existing transaction subscriber stores +the current serialization in the shared value ref and writes it directly to +`HTMLInputElement.value` before a transaction returns. Subsequent React renders +read that same serialization rather than restoring an older server/default +value. This preserves synchronous `FormData` and native-submission behavior +without waiting for a React state update. + +If `formFieldName` is absent, no document-bearing hidden input is rendered. A +reset-only unnamed field remains empty. `CollaborativeCwlEditor` does not place +Yjs content into server markup because its authoritative document becomes +available only after the host-owned client collaboration lifecycle is bound. + +## Root cause and test-first evidence + +The pre-existing SSR shell rendered a named hidden input but gave it no server +value because the TipTap editor is intentionally `null` until client hydration. +The browser later received a synchronized value, but an enterprise host reading +or submitting server HTML before JavaScript ran could not receive the configured +document value. + +Commit `fb37cd9a4344a3b369553c49eb4bf557c082c7da` introduced a Node +`renderToString` regression first. CI run `31177509823` failed because the +server output contained the expected hidden input, `name`, and external `form` +association but omitted the escaped `value` attribute. The implementation then +added the explicit pre-hydration handoff and focused browser-DOM tests for +initial-value retention, pre-editor prop updates, and reset-only non-disclosure. + +A later review found that this doctoring record described the field as an +uncontrolled `defaultValue` input even though the implementation deliberately +uses a `readOnly` controlled `value` backed by the same serialization ref that +serves synchronous DOM writes. A deterministic documentation contract was added +first to reject that mismatch, then this record was corrected without changing +runtime behavior. + +## HTML and hydration contract + +The WHATWG HTML Standard defines a hidden input as a submittable +form-associated element whose data is an arbitrary string. Its `name` supplies +the form-entry name, its `form` attribute can associate it with an external form, +and its value supplies the submitted value unless the control is disabled or +otherwise excluded by the form-data construction algorithm. + +React server APIs generate the initial HTML representation. `hydrateRoot` +expects the first client render to match that server output, so Inkspan renders +the same controlled value on the server and during hydration. The field is +`readOnly`: user editing is not an input path. Before TipTap initialization, prop +updates remain authoritative. Once the editor is initialized, synchronous +imperative writes and the shared serialization ref keep the native field aligned +with editor transactions; a later React render therefore consumes the latest +serialized value rather than a stale initial prop. + +This contract applies to `renderToString` and to streaming React server APIs +because the component tree emits the same input element. It does not claim that +a server renderer has created or validated a TipTap document. + +## Security and privacy boundary + +A hidden field is hidden from ordinary layout, not secret. Its value is present +in page source, the DOM, browser developer tools, form submission, extensions, +and any intermediary that can observe the response or request. Hosts must not +use `formFieldName` when server markup must omit the document body. They must +apply appropriate response caching, content classification, transport security, +and tenant authorization before rendering private content. + +Every hidden-field value is client-controlled submission data. The server must +revalidate document syntax and resource limits, authenticate the request, +authorize the actor and tenant, enforce CSRF protections, and perform durable +concurrency and persistence checks. The field is not an authorization grant, +signature, CSRF token, tenant identifier, integrity proof, strong entity tag, or +proof that the browser submitted an unchanged server value. + +OWASP documents hidden fields as one possible transport for synchronized CSRF +tokens, while also requiring server validation and protecting those tokens from +logs and URLs. Inkspan does not generate, store, or validate CSRF tokens; that +responsibility remains host-owned and separate from the document field. + +Inkspan owns deterministic value selection, safe React serialization, hydration +continuity, and synchronous post-hydration mirroring. Hosts retain form action +and method, authentication, authorization, tenant isolation, CSRF defenses, +request-size limits, persistence, credentials, migration, retention, audit, +conflict policy, and model-use policy. + +## Standalone and modular MSA behavior + +The feature requires no transport, database, environment variable, provider, +model, or collaboration dependency. A standalone application can submit the +native field directly. A naruon `compose` or `ui.panel` host can associate the +field with its own form through `formId`, including when the form is outside the +editor subtree. + +Server-selected data and authorization remain host concerns. Inkspan does not +create a database object. A host persistence object introduced for submitted +editor data should use at least two descriptive words and `snake_case` by +default, or a valid ecosystem-required CamelCase/PascalCase form. + +## Verification and rollback + +Exact-head tests require: + +- controlled `value` precedence over `defaultValue` in server markup; +- `readOnly` controlled-field semantics across SSR, hydration, and rerenders; +- safe HTML-attribute escaping by React; +- preservation of native `name` and external `form` association; +- no TipTap/ProseMirror server construction; +- no document-bearing field when form serialization is not configured; +- retention and updates before the editor exists; +- empty reset-only unnamed fields; +- existing synchronous transaction-to-`FormData`, disabled-field, mode-change, + reset, external-form, and collaborative client contracts; and +- repository-wide 100% production statement, branch, function, and line + coverage plus packaging and SSR-import evidence. + +Rollback removes the initial-value prop path and this evidence. No stored-data, +database, tenant, credential, migration, or release rollback is required. +Before rollback, hosts relying on no-JavaScript form submission must provide an +equivalent server-owned field or accept that the initial form entry is empty. + +No formal WHATWG, React, OWASP, accessibility, security, or privacy conformance +is claimed. + +## APA 7 references + +Meta Platforms, Inc. (n.d.). *hydrateRoot*. React. Retrieved August 7, 2026, +from https://react.dev/reference/react-dom/client/hydrateRoot + +Meta Platforms, Inc. (n.d.). *renderToString*. React. Retrieved August 7, 2026, +from https://react.dev/reference/react-dom/server/renderToString + +OWASP Foundation. (n.d.). *Cross-site request forgery prevention cheat sheet*. +OWASP Cheat Sheet Series. Retrieved August 7, 2026, from +https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html + +WHATWG. (2026). *HTML Standard: Form control infrastructure*. Retrieved August +7, 2026, from +https://html.spec.whatwg.org/multipage/form-control-infrastructure.html + +WHATWG. (2026). *HTML Standard: The input element*. Retrieved August 7, 2026, +from https://html.spec.whatwg.org/multipage/input.html diff --git a/docs/server-rendering.md b/docs/server-rendering.md index c8527472..dabcb0da 100644 --- a/docs/server-rendering.md +++ b/docs/server-rendering.md @@ -13,13 +13,52 @@ mismatches caused by constructing an editor view during server rendering. collaborative surface, the non-interactive connection-status region. - Server output does not contain a ProseMirror editable view or serialize the initial document into presentation markup. -- The TipTap editor, event handlers, native-form mirror, toolbar, and Yjs binding - become available after client hydration. +- When a standalone host explicitly supplies `formFieldName`, the selected + Markdown or HTML prop value is present in one server-rendered hidden field so + native form submission does not depend on TipTap having hydrated. +- Without `formFieldName`, server output contains no document-bearing native + field. Reset-only unnamed fields remain empty. +- The TipTap editor, event handlers, toolbar, and Yjs binding become available + after client hydration. A configured native field then follows editor + transactions synchronously. - `onReady` and the imperative `CwlEditorHandle` remain client lifecycle surfaces. Hosts must not expect either during server rendering. - The server-rendered shell does not perform network requests, open a collaboration provider, persist content, or destroy a host-owned `Y.Doc`. +## Native form serialization before hydration + +`CwlEditor` selects a controlled `value` before `defaultValue`, exactly as it +does for the initial editor document. If `formFieldName` is configured, that +selected value is supplied to a `readOnly` controlled hidden input. React escapes +HTML attribute syntax; HTML-mode content remains an ordinary string value rather +than becoming page markup. + +The same controlled value is rendered on the server and during the first client +render, satisfying React's requirement that initial hydration output match. +Until TipTap exists, prop updates remain authoritative for the native field. Once +the editor is initialized, Inkspan's transaction listener records the current +serialization in the shared value ref and writes it directly into +`HTMLInputElement.value` before each document-changing transaction returns. +Subsequent React renders consume that same serialized value, preserving immediate +`FormData` construction and browser submission without restoring stale initial +content. + +A hidden field is not a secrecy control. Its content is visible in the response, +page source, DOM, browser tools, and submitted request. Treat it as +client-controlled submission data. Hosts must authenticate and authorize the +request, validate the submitted document and limits, apply CSRF protection, +enforce tenant isolation, and perform durable concurrency and persistence checks +server-side. Do not enable `formFieldName` when the document body must be absent +from server markup or intermediate caches. + +`CollaborativeCwlEditor` does not serialize Yjs content into the server shell. +The host-owned `Y.Doc` and provider become authoritative only in the client +collaboration lifecycle; its native form mirror is populated after that binding +exists. See +[`docs/doctoring/ssr-native-form-serialization.md`](doctoring/ssr-native-form-serialization.md) +for the test-first rationale, security limits, rollback, and APA 7 references. + ## Next.js App Router Inkspan uses React hooks and remains an interactive client component. In a @@ -61,8 +100,9 @@ and document workspaces have different layout contracts. through server boundaries. - Instantiate browser transports, credentials, collaboration providers, and `Y.Doc` ownership within the authorized client/service integration layer. -- Treat initial editor content as client presentation state; authorize and - validate all persisted mutations at the service boundary. +- Treat initial editor content and native form values as client presentation and + submission state; authorize and validate all persisted mutations at the + service boundary. - Do not embed secrets in editor props, server-rendered markup, collaboration awareness, hidden form fields, or inline diagnostics. - Preserve descriptive nonnumeric identifiers across document, user, session, @@ -72,13 +112,17 @@ and document workspaces have different layout contracts. Repository tests run standalone and collaborative components through `react-dom/server` in a Node environment and assert that the stable shell is -emitted without a ProseMirror view or document-body leakage. The normal browser -suite then verifies client initialization, editing, accessibility, forms, -collaboration, and the repository-wide 100% coverage gate. +emitted without a ProseMirror view. They additionally prove opt-in controlled- +value precedence, React attribute escaping, external form association, and +opt-out document non-disclosure. The normal browser suite verifies the +SSR-to-editor field handoff, synchronous transaction mirroring, editing, +accessibility, forms, collaboration, and the repository-wide 100% coverage gate. ## References - TipTap React SSR guidance: - TipTap performance and `immediatelyRender`: - React `renderToString`: +- React `hydrateRoot`: - Next.js client components: +- WHATWG HTML form controls: diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 3cee8c41..59abb4e4 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -60,6 +60,7 @@ export const CwlEditor = forwardRef( ref, ) { const isControlled = value !== undefined; + const selectedDocumentValue = value ?? defaultValue ?? ''; const emittingRef = useRef(false); const editorInstanceRef = useRef(null); const modeRef = useLatestRef(mode); @@ -111,7 +112,7 @@ export const CwlEditor = forwardRef( image, onImageError: reportImageError, }), - content: editorValueToHtml(value ?? defaultValue ?? '', mode), + content: editorValueToHtml(selectedDocumentValue, mode), editorProps: { attributes: editorAttributes, }, @@ -223,6 +224,7 @@ export const CwlEditor = forwardRef( formFieldName={formFieldName} formId={formId} formFieldDisabled={formFieldDisabled} + formFieldInitialValue={selectedDocumentValue} onFormReset={editor && observesFormReset ? handleFormReset : undefined} /> ); diff --git a/src/components/EditorFormField.ssrHandoff.test.tsx b/src/components/EditorFormField.ssrHandoff.test.tsx new file mode 100644 index 00000000..4adac185 --- /dev/null +++ b/src/components/EditorFormField.ssrHandoff.test.tsx @@ -0,0 +1,178 @@ +import type { Editor } from '@tiptap/react'; +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { EditorFormField } from './EditorFormField.js'; + +afterEach(cleanup); + +/** Return the native field created by the focused pre-hydration fixture. */ +function nativeField(container: HTMLElement): HTMLInputElement { + const field = container.querySelector( + '[data-inkspan-form-field]', + ); + if (!field) throw new Error('Inkspan native form field was not rendered'); + return field; +} + +describe('EditorFormField SSR-to-editor handoff', () => { + it('retains and updates the selected value until TipTap exists', () => { + const { container, rerender } = render( + , + ); + + expect(nativeField(container).value).toBe('# Server draft'); + + rerender( + , + ); + + expect(nativeField(container).value).toBe('# Updated before hydration'); + }); + + it('keeps prop updates authoritative while a TipTap instance is not initialized', async () => { + let editorHtml = ''; + let createListener: (() => void) | undefined; + const editor = { + isInitialized: false, + getHTML: vi.fn(() => editorHtml), + on: vi.fn((event: string, listener: () => void) => { + if (event === 'create') createListener = listener; + }), + off: vi.fn(), + } as unknown as Editor; + const { container, rerender } = render( +
+ + , + ); + + rerender( +
+ + , + ); + + expect(nativeField(container).value).toBe('# Updated before create'); + + const form = container.querySelector('form'); + if (!form) throw new Error('Focused form fixture was not rendered'); + form.reset(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(nativeField(container).value).toBe('# Updated before create'); + + editorHtml = '

Client draft

'; + createListener?.(); + expect(nativeField(container).value).toContain('# Client draft'); + }); + + it('keeps the server value until a delayed TipTap create event becomes authoritative', () => { + let editorHtml = ''; + let createListener: (() => void) | undefined; + const editor = { + isInitialized: false, + getHTML: vi.fn(() => editorHtml), + on: vi.fn((event: string, listener: () => void) => { + if (event === 'create') createListener = listener; + }), + off: vi.fn(), + } as unknown as Editor; + const { container, rerender } = render( + , + ); + + rerender( + , + ); + + expect(nativeField(container).value).toBe('# Server draft'); + + editorHtml = '

Client draft

'; + createListener?.(); + + expect(nativeField(container).value).toContain('# Client draft'); + }); + + it('restores the live editor value after an unhandled native form reset', async () => { + let editorHtml = '

Initial

'; + let transactionListener: + | ((event: { transaction: { docChanged: boolean } }) => void) + | undefined; + const editor = { + getHTML: vi.fn(() => editorHtml), + on: vi.fn( + ( + event: string, + listener: (event: { transaction: { docChanged: boolean } }) => void, + ) => { + if (event === 'transaction') transactionListener = listener; + }, + ), + off: vi.fn(), + } as unknown as Editor; + const { container } = render( +
+ + , + ); + const form = container.querySelector('form'); + if (!form) throw new Error('Focused form fixture was not rendered'); + + editorHtml = '

Current

'; + transactionListener?.({ transaction: { docChanged: true } }); + expect(nativeField(container).value).toBe('

Current

'); + + form.reset(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(nativeField(container).value).toBe('

Current

'); + }); + + it('does not retain a document value for an unnamed reset-only field', () => { + const { container } = render( + undefined} + />, + ); + + expect(nativeField(container).value).toBe(''); + expect(nativeField(container).name).toBe(''); + }); +}); diff --git a/src/components/EditorFormField.tsx b/src/components/EditorFormField.tsx index 43ff6141..a8f9282c 100644 --- a/src/components/EditorFormField.tsx +++ b/src/components/EditorFormField.tsx @@ -10,6 +10,11 @@ export interface EditorFormFieldProps { name?: string; formId?: string; disabled?: boolean; + /** + * Selected standalone document value emitted in SSR markup and retained until + * the hydrated TipTap editor becomes authoritative for the native field. + */ + initialValue?: string; onFormReset?: (event: Event) => void; } @@ -22,10 +27,17 @@ export interface EditorFormFieldProps { * higher-level TipTap update event. The field's native value is written * synchronously before returning from each document transaction, so immediate * `FormData` construction or browser submission cannot observe a React-batched - * stale value. Reset-only unnamed fields skip document serialization entirely. - * When configured, the field observes the associated form's cancelable reset - * event and schedules editor work in the next task, after native dispatch and - * the reset algorithm have completed. + * stale value. During SSR-to-client handoff, the selected server value remains + * the controlled native value until TipTap reports that its document has been + * initialized; the create event then establishes editor authority without a + * transient empty submission value. The same serialized-value ref also backs + * React renders so a TipTap transaction-triggered parent render cannot restore + * an older server/default value after the synchronous DOM write. Every named + * field restores its live serialized value after the browser's native reset + * algorithm so a reset cannot silently desynchronize FormData from an editor + * that the host chose not to reset. Reset-only unnamed fields skip document + * serialization entirely. A configured host reset observer is invoked in the + * same next-task boundary, after native dispatch and reset processing. */ export function EditorFormField({ editor, @@ -33,20 +45,30 @@ export function EditorFormField({ name, formId, disabled, + initialValue = '', onFormReset, }: EditorFormFieldProps) { const fieldRef = useRef(null); - const serializedValueRef = useRef(''); + const serializedValueRef = useRef(initialValue); useEffect(() => { const field = fieldRef.current; /* v8 ignore next -- the effect runs only after the rendered field mounts. */ if (!field) return; - if (!editor || name === undefined) { + if (name === undefined) { serializedValueRef.current = ''; field.value = ''; return; } + if (!editor) { + serializedValueRef.current = initialValue; + field.value = initialValue; + return; + } + if (editor.isInitialized === false) { + serializedValueRef.current = initialValue; + field.value = initialValue; + } const synchronizeValue = () => { const nextValue = editorHtmlToValue(editor.getHTML(), mode); @@ -61,15 +83,17 @@ export function EditorFormField({ if (transaction.docChanged) synchronizeValue(); }; - synchronizeValue(); + if (editor.isInitialized !== false) synchronizeValue(); + editor.on('create', synchronizeValue); editor.on('transaction', handleTransaction); return () => { + editor.off('create', synchronizeValue); editor.off('transaction', handleTransaction); }; - }, [editor, mode, name]); + }, [editor, initialValue, mode, name]); useEffect(() => { - if (!onFormReset) return; + if (name === undefined && !onFormReset) return; const field = fieldRef.current; /* v8 ignore next -- the effect runs only after the rendered field mounts. */ if (!field) return; @@ -82,7 +106,7 @@ export function EditorFormField({ pendingResetTasks.delete(pendingTask); if (event.defaultPrevented) return; if (name !== undefined) field.value = serializedValueRef.current; - onFormReset(event); + onFormReset?.(event); }, 0); pendingResetTasks.add(pendingTask); }; @@ -104,6 +128,16 @@ export function EditorFormField({ name={name} form={formId} disabled={disabled} + readOnly + value={ + name === undefined + ? '' + : editor?.isInitialized === false + ? initialValue + : editor + ? serializedValueRef.current + : initialValue + } data-inkspan-form-field="" /> ); diff --git a/src/components/EditorFrame.tsx b/src/components/EditorFrame.tsx index aff8ffa9..9cf49c23 100644 --- a/src/components/EditorFrame.tsx +++ b/src/components/EditorFrame.tsx @@ -16,6 +16,8 @@ export interface EditorFrameProps { formFieldName?: string; formId?: string; formFieldDisabled?: boolean; + /** Selected standalone value rendered into an explicitly named SSR field. */ + formFieldInitialValue?: string; onFormReset?: (event: Event) => void; status?: ReactNode; } @@ -35,6 +37,7 @@ export function EditorFrame({ formFieldName, formId, formFieldDisabled, + formFieldInitialValue, onFormReset, status, }: EditorFrameProps) { @@ -76,6 +79,7 @@ export function EditorFrame({ name={formFieldName} formId={formId} disabled={formFieldDisabled} + initialValue={formFieldInitialValue} onFormReset={onFormReset} /> {status} diff --git a/src/components/editorServerRendering.test.tsx b/src/components/editorServerRendering.test.tsx index c93598b8..354cac12 100644 --- a/src/components/editorServerRendering.test.tsx +++ b/src/components/editorServerRendering.test.tsx @@ -19,6 +19,41 @@ describe('server rendering', () => { expect(html).not.toContain('Server draft'); }); + it('serializes configured native form data into the server shell', () => { + const html = renderToString( + , + ); + + expect(html).toContain('data-inkspan-form-field=""'); + expect(html).toContain('name="message_body"'); + expect(html).toContain('form="compose_form"'); + expect(html).toContain( + 'value="<p>Server & draft</p>"', + ); + expect(html).not.toContain('Ignored default'); + expect(html).not.toContain('ProseMirror'); + }); + + it('omits document content from the server shell when form serialization is disabled', () => { + const html = renderToString( + , + ); + + expect(html).not.toContain('data-inkspan-form-field'); + expect(html).not.toContain('Private server draft'); + }); + it('renders a stable collaborative shell without binding Yjs on the server', () => { const collaborationDocument = new Y.Doc(); try { diff --git a/src/ssrFormSerializationDocumentation.test.ts b/src/ssrFormSerializationDocumentation.test.ts new file mode 100644 index 00000000..6b0f8797 --- /dev/null +++ b/src/ssrFormSerializationDocumentation.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +/** Read one authoritative repository artifact for a deterministic docs contract. */ +function repositoryText(path: string): string { + return readFileSync(path, 'utf8'); +} + +describe('SSR native form serialization doctoring', () => { + it('records the opt-in value, hydration, and host security boundaries', () => { + const serverRendering = repositoryText('docs/server-rendering.md'); + const doctoring = repositoryText( + 'docs/doctoring/ssr-native-form-serialization.md', + ); + const changelog = repositoryText('CHANGELOG.md'); + + expect(serverRendering).toContain('formFieldName'); + expect(serverRendering).toContain('server-rendered hidden field'); + expect(serverRendering).toContain('client-controlled submission data'); + expect(serverRendering).toContain('CollaborativeCwlEditor'); + expect(doctoring).toContain('fb37cd9a4344a3b369553c49eb4bf557c082c7da'); + expect(doctoring).toContain('31177509823'); + expect(doctoring).toContain('defaultValue'); + expect(doctoring).toContain('readOnly'); + expect(doctoring).toContain('controlled React input'); + expect(doctoring).not.toContain( + 'uses React `defaultValue` for the server and hydration render', + ); + expect(serverRendering).not.toContain( + "supplied through React's `defaultValue` contract", + ); + expect(doctoring).toContain('hydrateRoot'); + expect(doctoring).toContain('WHATWG'); + expect(doctoring).toContain('OWASP Foundation'); + expect(doctoring).toContain('APA 7 references'); + expect(changelog).toContain('SSR native form field'); + }); +});