Test Surgical DOM Updates
Prove the smallest expected DOM mutation surface in a real compiled mini app while separately checking retained node identity.
Workflow
- Add the scenario under
tests/browser/corpus/apps/<app>/. Write a React-shaped.tsxcomponent that imports supported APIs fromreact; do not construct runtime bindings directly. - Import that
.tsxcomponent from a colocated.browser.test.ts. The browser Vitest config'svidact()plugin must compile it through@vidact/viteand the Rust compiler. Mount it withmountCompiledand drive state changes through DOM events. - Read
packages/test-support/src/mutations.tsand the closest compiled app test. Import its helpers from@vidact/test-support; do not create a second observer wrapper. - Mount before observing. Save every node whose identity matters, including unaffected siblings and keyed records.
- Locate a mutable leaf with
requireSingleDirectTextwhen it has one direct text child. Compiled binding children contain comment range markers, sofirstChildmay be aComment. - Wrap successful interactions with
captureMutations. It supports synchronous and asynchronous actions and drains observer delivery before returning. - Pass records to
assertMutationEnvelopewith the narrowest stable rules. Prefer an exacttarget; usewithinonly for an owned subtree whose internal cleanup or rendering may produce several legitimate records. - Assert node identity with Vitest
toBe. For keyed reorders, compare saved nodes in their new order; achildListrecord alone cannot distinguish movement from remounting. - For an action expected to throw, start
startMutationCapture, make the throwing assertion, then callstop()and assert the allowed records.stop()is idempotent. - Keep direct-runtime tests as focused infrastructure coverage, never as a replacement for a compiled app regression. Run the focused browser test, full browser corpus, and browser-corpus typechecking.
Patterns
Use an exact envelope for scalar and attribute bindings:
const text = requireSingleDirectText(element)
const capture = await captureMutations(host, () => increment.click())
expect(element).toBe(originalElement)
expect(capture.records).toHaveLength(1)
expect(() => assertMutationEnvelope(capture.records, [
{ type: 'characterData', target: text },
], 'label update')).not.toThrow()
Allow moves inside a keyed list, then prove identity separately:
const capture = await captureMutations(host, () => reverse.click())
expect(() => assertMutationEnvelope(capture.records, [
{ type: 'characterData', within: list },
{ type: 'childList', target: list },
], 'keyed reorder')).not.toThrow()
expect(list.querySelectorAll('li')[0]).toBe(savedSecondRow)
For a lower-level runtime test, prove a rejected update is atomic:
const recorder = startMutationCapture(host)
expect(() => updateWithDuplicateKeys()).toThrow(/duplicate key/i)
expect(recorder.stop()).toEqual([])
Rules
- Observe the update, never initial mounting or test-fixture construction.
- Exercise public React-shaped source through the Vite plugin. A generated JavaScript fixture or direct runtime setup alone is not corpus-level E2E evidence.
- Trigger behavior through browser-visible events where a user could do so; avoid reaching into compiled closures or calling runtime setters from app-corpus tests.
- Default to the helper's full subtree configuration: attributes, character data, and child lists with old values.
- Treat zero records as the correct envelope for a no-op update.
- Avoid asserting record order unless updater ordering is itself the contract.
- Assert exact record counts only when the platform operation has a stable count; keyed movement may emit multiple removal/addition records.
- Keep cleanup mutations inside the disposed owned subtree in the allowed envelope when branch disposal intentionally tears down nested ranges before removing the branch root.
- Do not use
innerHTMLequality as proof of surgical behavior; replacement can produce identical markup. - Do not infer more than the observer sees. Property writes such as
input.value, event-listener changes, ref callbacks, updater execution counts, and scope disposal need separate assertions. - Preserve tests that demonstrate intentionally non-surgical compatibility behavior; do not falsely label full rerenders as compiled surgical updates.
Verification
Run:
pnpm --filter @vidact/browser-corpus typecheck
pnpm --filter @vidact/browser-corpus test
If the browser server cannot bind inside the sandbox, request the normal browser-test permission rather than changing Vitest configuration.