Two attribute changes have already been detected. A component finishes its work, calls observer.disconnect(), and leaves an empty application ledger. The attribute still holds the final value, so a quick DOM inspection suggests everything succeeded. The missing evidence becomes visible only when another part of the application expects both eligible changes to have been accounted for.
disconnect() is not a request to flush the callback. Cleanup must choose whether pending notifications should be discarded or preserved. Either policy can be legitimate; silently implementing discard while promising preservation is the defect this playbook addresses.
The controlled example uses one owned element whose data-step attribute starts at "0". Observation begins, then synchronous writes change it to "1" and "2" in the same task. We compare immediate disconnection, draining before disconnection, ordinary callback delivery, draining without stopping, and re-observation after discarded records. The writes stay identical while the lifecycle order changes.
The acceptance contract is deliberately local: account for this observer's eligible records before a defined cutoff, through either its callback or an explicit drain. It does not turn DOM observation into a durable audit system. The fixture also distinguishes recorded old values from readings of the live target, then tests repeated cleanup, independent observer ownership, and a consumer that fails. Those controls make the repair reviewable: stopping observation and successfully processing its remaining evidence become separate, observable outcomes.
Define which pending records teardown must preserve
Write the policy before changing the cleanup helper. In this fixture, eligibility means an attribute mutation on the exact registered element, with the attribute name data-step, while this observer is registered. Initialization happens before registration. The preserve cutoff is the synchronous call that drains and immediately disconnects the observer after the two controlled writes.
Under a discard policy, the component owner explicitly accepts losing eligible records that remain pending when observation ends. A disposable display calculation might have no further use for them. Its acceptance criteria should say that stopping is sufficient and identify the downstream work that no longer requires those records.
Under a preserve policy, every eligible pre-cutoff record must enter the application's defined accounting path. Records already consumed through callbacks count toward that requirement; records still pending must be retrieved and handed to the same consumer. A final drain represents the remaining queue, not the observer's entire earlier lifetime.
For our synthetic schedule, the expected population is two records. The expected target, attribute, old values, and order are known independently of the result. A total of two unrelated records would fail the contract. Likewise, two invocations of a consumer carrying the same record would not compensate for a missing record.
The distinction complements event-listener identity during component cleanup: each resource needs an explicit owner and an explicit end condition. Here the review centers on a pending record queue and what happens to its contents at that end condition.
Keep the requirement narrow enough to test. It excludes unrelated attributes, other elements, business events that never change this attribute, and changes outside the registration interval. If a stakeholder actually needs those events, change the evidence design before declaring this observer complete. A carefully drained queue cannot supply evidence that was never eligible to enter it.
Build a deterministic browser fixture
Use an owned local directory, Node.js, npm, Playwright, and its installed Chromium executable. Each case creates one ordinary light-DOM div. The harness loads an HTML string into a fresh browser page for every case; no application server or remote document is involved. Package and browser installation require downloads, but the actual mutation experiment uses only local synthetic data.
The writer's execution used Chromium Headless Shell 141.0.7390.37, Playwright 1.56.1, and Node.js v24.19.0, headless on Linux x64. The preparation evidence supplied for comparison used Chromium 144.0.7559.96 through Playwright, without an exact runner version. These are distinct execution records. Neither browser is described as the newest release, and neither run certifies other browser engines.
Save the following three files together. The pinned dependency and explicit browser installation follow the Playwright library workflow; the assertions run in the supplied Node script, not an unstated test-runner configuration. The script records actual versions again in evidence.json.
package.json
{
"private": true,
"type": "module",
"scripts": { "test": "node run.mjs" },
"devDependencies": { "playwright": "1.56.1" }
}fixture.html
<!doctype html>
<html lang="en"><meta charset="utf-8"><title>Observer cutoff fixture</title>
<body><script type="module">
const options = {
attributes: true, attributeOldValue: true,
attributeFilter: ["data-step"]
};
const checkpoint = () => new Promise(resolve => queueMicrotask(resolve));
function makeOwner(el, failOnce = false) {
const ledger = [], failed = [];
let stopped = false, callbacks = 0, phase = "initial", lastDrained;
const callbackErrors = [];
function consume(records, route, batchPhase) {
if (failOnce) {
failOnce = false;
throw new Error("synthetic consumer failure"); // Before any append.
}
const rows = records.map(record => ({
route, phase: batchPhase, targetId: record.target.id,
sameTarget: record.target === el, type: record.type,
attributeName: record.attributeName, oldValue: record.oldValue,
currentAtConsume: record.target.getAttribute(record.attributeName)
}));
ledger.push(...rows);
}
function deliver(records, route, batchPhase) {
if (!records.length) return;
try { consume(records, route, batchPhase); }
catch (error) {
failed.push({ records, route, phase: batchPhase });
throw error;
}
}
const observer = new MutationObserver(records => {
callbacks++;
try { deliver(records, "callback", phase); }
catch (error) { callbackErrors.push(String(error)); }
});
function observe() { observer.observe(el, options); stopped = false; }
function discard() { observer.disconnect(); stopped = true; }
function endObservation() {
if (stopped) return 0;
const pending = observer.takeRecords();
observer.disconnect();
stopped = true;
lastDrained = pending;
deliver(pending, "drain", phase);
return pending.length;
}
function retryFailed() {
if (!stopped) throw new Error("Stop before retrying");
const batch = failed[0];
if (!batch) return 0;
consume(batch.records, batch.route, batch.phase);
failed.shift(); // A failed retry leaves the retained batch in place.
return batch.records.length;
}
observe();
return {
observer, ledger, failed, observe, discard, endObservation, retryFailed,
setPhase(value) { phase = value; },
drainOnly() { deliver(observer.takeRecords(), "drain", phase); },
get lastDrained() { return lastDrained; },
state() { return { ledger, stopped, callbacks, callbackErrors,
failedBatches: failed.length }; }
};
}
window.runCase = async function(mode) {
const el = document.createElement("div");
el.id = "owned-target";
el.setAttribute("data-step", "0");
document.body.append(el); // Registration starts after setup.
const a = makeOwner(el, mode === "consumer_error");
const b = mode === "ownership" ? makeOwner(el) : null;
let details = {};
const set = value => el.setAttribute("data-step", value);
try {
set("1");
set("2"); // No await between these writes and immediate cleanup.
if (mode === "ownership") {
a.endObservation();
await checkpoint();
b.setPhase("later"); set("3");
await checkpoint();
b.endObservation();
details.other = b.state();
} else if (mode === "repeat") {
details.drained = [a.endObservation(), a.endObservation()];
set("3"); await checkpoint();
details.drained.push(a.endObservation());
} else if (mode === "consumer_error") {
try { a.endObservation(); }
catch (error) { details.error = error.message; }
details.stoppedAtFailure = a.state().stopped;
details.retainedBeforeRetry = a.failed[0]?.records.length;
details.sameDrainedArray = a.failed[0]?.records === a.lastDrained;
details.repeatResult = a.endObservation();
details.retainedAfterRepeat = a.failed[0]?.records.length;
set("3"); await checkpoint(); // The retained targets remain live.
details.retried = a.retryFailed();
} else {
if (mode === "delivered") {
await checkpoint();
details.beforeStop = a.ledger.length;
if (a.ledger.length !== 2 || a.ledger.some((r, index) =>
r.route !== "callback" || r.phase !== "initial" ||
r.targetId !== "owned-target" || !r.sameTarget ||
r.type !== "attributes" || r.attributeName !== "data-step" ||
r.oldValue !== String(index) || r.currentAtConsume !== "2"))
throw new Error("Unexpected callback evidence before disconnect");
a.discard();
} else if (mode === "drain") a.endObservation();
else if (mode === "drain_only") a.drainOnly();
else if (mode === "discard" || mode === "reobserve") a.discard();
else throw new Error(`Unknown mode: ${mode}`);
details.secondDrain = a.observer.takeRecords().length;
a.setPhase("later"); set("3");
if (mode === "reobserve") {
a.observe(); a.setPhase("future"); set("4");
}
await checkpoint();
a.endObservation();
}
details.finalDrain = a.observer.takeRecords().length;
return { mode, ...details, ...a.state(),
finalValue: el.getAttribute("data-step") };
} catch (error) {
return { mode, ...details, ...a.state(), probeError: String(error),
finalValue: el.getAttribute("data-step") };
} finally {
a.discard(); b?.discard(); el.remove();
}
};
</script></body></html>run.mjs
import assert from "node:assert/strict";
import { readFile, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { chromium } = require("playwright");
const evidence = {
status: "RERUN", utc: new Date().toISOString(), node: process.version,
playwright: require("playwright/package.json").version,
runner: "Node.js assertion script with Playwright", engine: "chromium",
headless: true, platform: process.platform, arch: process.arch,
invocation: process.argv, cases: []
};
const modes = ["discard", "drain", "delivered", "drain_only", "reobserve",
"repeat", "ownership", "consumer_error"];
const row = (route, phase, oldValue, currentAtConsume) => ({
route, phase, targetId: "owned-target", sameTarget: true,
type: "attributes", attributeName: "data-step", oldValue, currentAtConsume
});
const initial = route => [row(route, "initial", "0", "2"),
row(route, "initial", "1", "2")];
const expected = {
discard: [], drain: initial("drain"), delivered: initial("callback"),
drain_only: [...initial("drain"), row("callback", "later", "2", "3")],
reobserve: [row("callback", "future", "3", "4")],
repeat: initial("drain"), ownership: initial("drain"),
consumer_error: [row("drain", "initial", "0", "3"),
row("drain", "initial", "1", "3")]
};
function check(result) {
const mode = result.mode;
assert.equal(result.probeError, undefined);
assert.deepEqual(result.ledger, expected[mode], `${mode}: complete ledger`);
assert.equal(result.stopped, true);
assert.equal(result.failedBatches, 0);
assert.deepEqual(result.callbackErrors, []);
assert.equal(result.finalDrain, 0);
assert.equal(result.finalValue, mode === "reobserve" ? "4" : "3");
assert.equal(result.callbacks,
["delivered", "drain_only", "reobserve"].includes(mode) ? 1 : 0);
if (modes.indexOf(mode) < 5) assert.equal(result.secondDrain, 0);
if (mode === "delivered") assert.equal(result.beforeStop, 2);
if (mode === "repeat") assert.deepEqual(result.drained, [2, 0, 0]);
if (mode === "ownership") {
assert.deepEqual(result.other.ledger, [...initial("callback"),
row("callback", "later", "2", "3")]);
assert.equal(result.other.callbacks, 2);
assert.equal(result.other.stopped, true);
assert.equal(result.other.failedBatches, 0);
assert.deepEqual(result.other.callbackErrors, []);
}
if (mode === "consumer_error") {
assert.equal(result.error, "synthetic consumer failure");
assert.equal(result.stoppedAtFailure, true);
assert.equal(result.retainedBeforeRetry, 2);
assert.equal(result.sameDrainedArray, true);
assert.equal(result.repeatResult, 0);
assert.equal(result.retainedAfterRepeat, 2);
assert.equal(result.retried, 2);
}
}
let browser, context;
const timer = setTimeout(() => { browser?.close().catch(() => {}); }, 30000);
try {
const html = await readFile(new URL("fixture.html", import.meta.url), "utf8");
browser = await chromium.launch({ headless: true });
evidence.browser = browser.version();
context = await browser.newContext();
for (const mode of modes) {
const page = await context.newPage(); // Fresh document for every case.
const pageErrors = [];
page.on("pageerror", error => pageErrors.push(String(error)));
try {
await page.setContent(html, { waitUntil: "load" });
const result = await page.evaluate(mode => window.runCase(mode), mode);
result.pageErrors = pageErrors;
evidence.cases.push(result); // Retain actual data before asserting.
assert.deepEqual(pageErrors, []);
check(result);
} finally { await page.close(); }
}
evidence.status = "ACCEPT";
} catch (error) {
evidence.status = error instanceof assert.AssertionError ? "REPAIR" : "RERUN";
evidence.error = String(error.stack || error);
process.exitCode = 1;
} finally {
clearTimeout(timer);
try {
try { await context?.close(); }
finally { await browser?.close(); }
}
catch (error) {
evidence.status = "RERUN"; evidence.cleanupError = String(error);
process.exitCode = 1;
}
await writeFile(new URL("evidence.json", import.meta.url),
JSON.stringify(evidence, null, 2) + "\n");
console.log(JSON.stringify({ status: evidence.status,
browser: evidence.browser, playwright: evidence.playwright,
node: evidence.node,
counts: evidence.cases.slice(0, 5).map(test => test.ledger.length),
controls: evidence.cases.slice(5).map(test => test.mode) }, null, 2));
if (evidence.error) console.error(evidence.error);
}Run from that directory:
npm install --ignore-scripts --no-audit --no-fund
npx playwright install chromium --only-shell
npm testKeep the generated lockfile for subsequent runs and use npm ci to reinstall it. The expected successful summary is ACCEPT, five counts [0, 2, 2, 3, 1], and all three named additional controls. A nonzero exit requires investigation; retain the actual JSON instead of replacing its values with the expected table.
Use Node.js 18 or later, as permitted by the pinned package, on a host with the browser's system dependencies installed. Failure to launch is an environment result, not evidence about record delivery. The runner's thirty-second timer requests closure of an already launched browser as a safeguard against stalled runs. It does not impose a hard deadline on startup, final cleanup, or writing evidence, and it is never used to decide whether a callback should have arrived. Pages, context, browser, and the owned element have explicit cleanup paths.
The returned stopped flag is sampled before emergency finalizers run. Cleanup in finally protects the test environment without turning a failed boundary into a passing result. Inspect the returned state and error fields before considering that final safeguard.
Keep registration scope narrow and explicit
The MDN observation-options reference, modified June 23, 2025 and consulted October 17, 2026, explains the filter and old-value options used here. The fixture explicitly enables attributes, requests old values, and filters to data-step; subtree remains false. It does not observe element insertion, other attribute names, child-list changes, or character data.
The ledger lives in JavaScript data outside the observed DOM. Both writes and immediate cleanup run inside one browser evaluation without an intervening await. Readers strengthening their frontend JavaScript foundations should preserve that boundary while experimenting: moving cleanup into another scheduled operation changes the experiment.
Observe ordinary callback delivery as the baseline
Start with delivered, where observation is allowed to report the two writes normally. This establishes that the element, options, callback, and ledger work before the teardown order is challenged. Without a positive delivery control, an empty discard ledger could be caused by a registration mistake or a broken consumer.
The two writes occur synchronously. The fixture then awaits a promise whose resolver is queued with queueMicrotask. In this controlled schedule, the mutation notification was queued before that marker, and the consumer does not create further mutations. The checkpoint gives that already queued notification an opportunity to run; it is not an arbitrary delay chosen to make a flaky test pass.
The DOM Standard's MutationObserver algorithms define a per-observer record queue and microtask-based notification. The notification algorithm takes the queued records and invokes the callback when its collected list is nonempty. The standard is a living specification, consulted October 17, 2026, rather than a release note for the browser used here.
The baseline records two ledger rows through the callback route. Their old values are "0" and "1", in that order. Both identify owned-target, satisfy the reference comparison record.target === el, report type attributes, and name data-step. These fields prevent a plausible count from hiding the wrong observation scope.
Before disconnecting, the browser case checks that the ledger contains two rows. The host runner also compares the complete returned ledger with its literal expectation. The fixture then stops observation and writes "3" as a later-change control. No additional row should appear for that observer.
Treat the checkpoint as part of the fixture's stated assumptions. It does not promise to finish arbitrary chains of application microtasks, observer-induced writes, or asynchronous persistence. If a future test introduces such work, synchronize on its actual completion condition and redefine the expected batches. Adding a longer sleep would conceal the changed contract.
For this baseline, one callback carries two records. A callback count is therefore a different measurement from a record count. Keep both in the evidence: two detected writes need not mean two callback invocations, and a successful callback invocation says nothing by itself about downstream acknowledgment.
For manual inspection, examine the returned plain ledger rows rather than relying on an expanded browser-console view of native target objects. The ledger captures the exact strings sampled by this consumer, making the evidence comparable with the literal expectations in the runner.
Show what immediate disconnect discards
Now run discard in a fresh document. The target again starts at "0", and the same synchronous writes set it to "1" and "2". This time the observer is disconnected before a callback checkpoint. No earlier callback has consumed the records, and no drain has retrieved them.
The observed ledger is empty. An immediate takeRecords() returns an empty array. The DOM still contains data-step="2" at the cleanup boundary because disconnection does not reverse the writes. After the later unobserved change to "3", the fixture returns a final value of "3" and still reports zero ledger rows.
The MDN disconnect() reference explicitly states that detected but undelivered notifications are discarded, and points to takeRecords() when the application needs to retain them. This is expected API behavior. The defect lies in applying it to a preservation requirement without first retrieving the queue.
This negative control is useful precisely because the target looks reasonable. A test that asserts only the final attribute value would pass both the preserve and discard cases. It would validate the writer's final DOM state while leaving the observer's accounting requirement untested.
The empty second drain also has limited meaning. It reports the queue's contents at that moment; it does not identify why the queue became empty. A callback could have consumed records, a previous drain could have removed them, or disconnection could have discarded them. The schedule and ledger distinguish those explanations.
Do not confuse a queued notification with delivered records
Notification scheduling and record retention are separate pieces of the contract. A notification can already be scheduled while its observer's queue is subsequently cleared. When that notification is processed, cleared records are unavailable for delivery. The existence of scheduled work does not restore them.
The important observable sequence is: writes occur, immediate cleanup clears pending records, then the controlled checkpoint runs with no records to consume. There is no need to speculate about browser threads or hidden implementation machinery to explain the result.
This also clarifies the wording of a teardown review. “The browser noticed the changes” is weaker than “the consumer received both records.” “The callback was scheduled” is weaker than “the ledger contains the required entries.” Require the latter evidence if that is what the application depends on.
If discard is intentional, keep this case as its acceptance test. If preserve is required, classify the same result as REPAIR. Changing a label does not recover the lost records; it makes the mismatch explicit so the implementation can be corrected for the next observation interval.
Drain records and stop observation in one synchronous boundary
The preserve boundary has three operations in a fixed order:
const pending = observer.takeRecords();
observer.disconnect();
consume(pending, "drain");First acquire the pending batch. Then stop further observation. Finally hand the application-owned batch to its consumer. In the fixture, endObservation() implements this sequence and sets its stopped state before invoking the consumer.
The MDN takeRecords() reference, modified July 26, 2024 and consulted October 17, 2026, documents returning pending records while emptying the queue. Its example drains, disconnects, and then processes the retrieved batch. That executable ordering is the relevant guide where the nearby explanatory prose is ambiguous about processing time.
No await, timer, logging hook, or application callback belongs between acquisition and disconnection in this policy. The fixture uses native methods on its own observer and performs those two calls back to back. This defines a small boundary that a reviewer can inspect directly.
Stopping before consumption matters because consumption is application work. If it synchronously changed the watched attribute while observation remained active, it could extend the population of eligible records. If it threw before a later disconnect, the owner could leave observation running unintentionally. The selected order settles registration before asking the consumer to succeed.
In the drain case, the pending array contains the two original records. The ledger receives both through the drain route, with old values "0" and "1". A second drain is empty. The subsequent change to "3" adds no callback row, so preservation and stopping are both tested.
Notice what the boundary does not include: successful persistence, external acknowledgment, or completed business processing. The returned array is now available to application code. Whether that code retains and handles it correctly is the next ownership question.
The disconnect call after acquisition does not erase the application-owned pending array. That is the critical handoff: the observer's internal queue has been emptied, but the caller still holds the records returned by the drain. The failure control later checks that ownership directly instead of trying to retrieve the batch from the observer a second time.
During an operational repair, replace the disconnect-first path where the preservation policy applies, then run the same negative and positive controls. Preserve the intentional discard path for callers whose policy allows it. A helper that always preserves without identifying its consumer can create a different ambiguity: retrieved evidence with nobody responsible for completing its processing.
Use one consumer for callback and drained records
Callback delivery and manual draining should converge on one record-normalization function. In the complete fixture, consume() maps each native record into the same fields and appends those rows to the same JavaScript ledger. deliver() supplies a route and retains a failed batch. The actual browser callback and endObservation() both call that wrapper.
The route describes how the application received a batch: callback or drain. It does not change which target, attribute, or old value the record represents. The phase label describes the synthetic schedule: initial writes, a later write while observation remains active, or a future write after re-registration. Neither label is a native MutationRecord property.
This separation keeps comparisons meaningful. The baseline and preserve cases have identical expected mutation fields, with only their route differing. If one path drops oldValue, rewrites target identity, or suppresses empty-looking values differently, a shared count would hide the inconsistency. Full-row assertions expose it.
The owner also separates repeated teardown from retry. endObservation() returns immediately once stopped is true. It does not consume the previous drain again, even if another caller repeats cleanup after a consumer failure. retryFailed() is a separate, explicit action that requires the observer to be stopped.
The synthetic failure happens before normalization or ledger append. The failing batch retains the actual records array, its route, and its phase. A later retry removes that retained batch only after consumption succeeds. This is a deliberately limited recovery model, whose test does not claim safety for arbitrary partially completed side effects.
The neighboring explanation of why cloned buttons need explicit event bindings reinforces the value of validating behavior through its actual execution path. In this fixture, the element is created once, and both delivery paths are exercised directly against the native observer.
Keep the drain consumer from mutating the observed target
The evidence sink should not change data-step while inspecting its records. Keeping the ledger in a JavaScript array makes that condition visible and easy to review. Debug rendering can happen afterward through a separate owner, once the observation boundary has closed.
With this narrow filter, not every DOM change would be eligible. The reason for the external ledger is to avoid coupling evidence collection to the observed state and to keep future changes to the filter from silently changing the experiment. It also makes accidental consumer writes easier to detect.
The consumer must report failure without implying successful accounting. In the fixture, callback errors are recorded, drained-path errors propagate, and failed arrays remain reachable. The runner checks the error state as well as ledger contents. A nonempty failed-batch list is an unfinished handoff, even when observation has stopped cleanly.
Finally, retain the owner while it has unfinished work. Releasing the only reference to the retained array would discard the very evidence the drain was intended to preserve.
Prove that takeRecords alone leaves observation active
The drain_only case tests an attractive but incomplete cleanup: acquire the pending records, consume them, and assume an empty queue means the observer has finished. After the initial two writes, the fixture drains two records through the manual route. Its immediate second drain returns zero.
Then it writes data-step="3" without disconnecting. At the next controlled checkpoint, the still-active observer delivers one new record through its callback. That record has old value "2" and reads current value "3". The ledger now contains three rows, even though the queue was demonstrably empty immediately before the later write.
This is the required negative control for the distinction between queue state and registration state. The first drain consumed a population that existed at one moment. It did not end the interval during which new matching mutations could become eligible.
Read the routes and phases together. The first two rows are drain/initial; the third is callback/later. The new callback row is not a duplicate of a drained record. Its old value and schedule identify a different mutation, admitted because observation continued.
The fixture eventually calls endObservation() to stop the observer safely. At that later boundary, the callback has already consumed the third row, so there is no remaining batch to add. This final cleanup prevents the negative control itself from leaving a live observer behind.
Two incomplete repairs now have distinct signatures. Disconnecting first produces zero rows where preservation requires two. Taking records without stopping produces a later callback where teardown requires observation to have ended. A repair needs the ordered pair of operations and assertions for both outcomes.
In a code review, ask where the returned records go and where registration actually ends. A helper named flush() may correctly drain a queue while making no stopping promise. A helper named cleanup() may need both behaviors. The name alone is insufficient evidence, so the caller's policy and the helper's return state should make the difference visible.
For this playbook's preserve-and-stop contract, the successful postcondition is two accounted initial records and no record for the controlled later write. Merely checking that takeRecords().length is zero cannot establish either part of that postcondition on its own.
Inspect what MutationRecord does not snapshot
The original two records carry old values "0" and "1". Yet both report "2" when the consumer reads record.target.getAttribute("data-step"). This occurred in both the writer's ordinary-callback case and the drain-before-disconnect case. It also agrees with the supplied Chromium 144 preparation evidence.
The apparent contradiction disappears when the fields are named accurately. oldValue is recorded mutation information. target identifies the affected node, and the explicit getAttribute call reads that node when consumption happens. After both initial writes, that node currently holds "2".
The MutationRecord interface reference describes the affected target, attribute identity, and old value; it provides no corresponding per-record newValue property. Calling our derived field currentAtConsume preserves that distinction. Naming it newValue would misrepresent what the code measured.
Initial record | Recorded oldValue | Live currentAtConsume | Value written by the known fixture schedule |
First | "0" | "2" | "1" |
Second | "1" | "2" | "2" |
The last column comes from the authored test schedule. It is not extracted from an undisclosed property of the record. Keeping those evidence origins separate lets the fixture demonstrate the live-reference behavior without inventing data.
Delayed consumption makes the distinction even clearer. In the added consumer-error control, the original batch is retained, observation stops, and the element changes to "3" before retry. The successfully retried rows still have old values "0" and "1", but their live readings are now both "3". Retaining a native record did not freeze its target's attribute state.
Once consume() writes a string into currentAtConsume, that ledger field is ordinary application data. A later target mutation does not revise the stored string. This is why the first two rows remain "2" in the drain-only ledger while its final row records "3". Those samples were taken at different times.
Name the limits of the ledger honestly
The fixture knows exactly which two initial writes it performed. It can therefore reconcile those expected records and their order. That is a useful test contract, not a general reconstruction algorithm for arbitrary mutation batches.
Do not infer a complete history of new values by combining unrelated old values, later DOM reads, and assumptions about missing intervals. An observer configured for this one attribute has no evidence for unobserved changes elsewhere. A drained batch also carries no application transaction boundary or durable acknowledgment simply because it was successfully returned.
Describe the ledger as controlled notification accounting. It is neither a security provenance log nor an event-sourced history of the whole interface. If a consumer requires the values of application commands at their creation time, that requirement needs its own explicit evidence source; relabeling live DOM readings cannot satisfy it.
Reconnect only for future observation, not lost history
The reobserve case deliberately creates a gap. It uses the same observer instance throughout, making the limits of re-registration easy to see. The initial two records are discarded; a later registration is then given one new eligible write.
The precise sequence is:
Create the element with data-step="0", then begin observation.
Synchronously write "1" and "2".
Disconnect immediately, discarding the pending pair.
Confirm that a subsequent drain is empty.
Write "3" while observation is stopped.
Re-observe the same element with the same options.
Write "4", then allow the controlled callback checkpoint.
The writer's run returned one ledger record, through the callback route and the fixture's future phase. Its old value was "3", and its live reading was "4". The final DOM value was also "4".
That result accounts for the new registration interval. It does not restore the original pair or capture the unobserved transition from "2" to "3". A reference to the same observer object is not an archive of its discarded queue.
Keep this control when a component can begin observation again after a stop. It detects cleanup helpers that accidentally rely on reconnecting to make pending evidence reappear. It also confirms that a future eligible mutation still works, so the earlier absence cannot be explained by a permanently unusable observer.
The fixture owner's observe() method resets its local stopped flag when it registers again. That makes the synthetic new interval explicit. An application adopting a similar owner should ensure its lifecycle state follows the actual registration transitions; an old Boolean value must not silently suppress cleanup for a newly active interval.
A missing interval requires an honest operational decision. If preservation was required for the discarded pair, mark the interval incomplete. Repair the next cutoff and retain the evidence of failure. Reading the final value, reconnecting, or labeling the next record “recovered” cannot demonstrate that the earlier required records were processed.
If records were consumed during an earlier interval, keep that application ledger separately from the observer's registration state. Starting observation again should neither erase acknowledged rows nor imply that missing rows from the stopped interval now exist.
Handle teardown failures without fabricating completeness
Preservation moves responsibility into application code. The added consumer_error case makes that handoff observable by throwing before any normalization or ledger append. It tests an application failure after the browser boundary, not a failure of takeRecords() itself.
The owner drains the original pair, disconnects, sets stopped, and invokes the consumer. When the consumer throws, the wrapper retains the same two-record array with its original route and phase. The test verifies both array identity and retained length, rather than assuming that an error message proves preservation.
Repeated endObservation() returns zero without retrying the batch. The target then changes to "3" while stopped, and no callback arrives. An explicit retryFailed() consumes the retained pair; only success removes it from the failure list. The changed live readings are expected and checked.
Keep four outcomes distinct: registration stopped, pending records drained, application ledger updated, and downstream processing acknowledged. The local fixture proves the first three for its specified sink and recovery sequence. It does not perform durable persistence or claim that external processing completed.
If a real consumer can partially succeed, replaying the whole batch might repeat completed side effects. Define partial-progress tracking or another appropriate acknowledgment policy before adopting a retry operation. The demonstrated failure occurs before any append, so it deliberately avoids making that broader promise.
Do not treat the error-path cleanup's zero return as an acknowledgment. In this helper it means the observer was already stopped and no new drain occurred. The retained-batch count remains a separate signal, and it stays nonzero until the explicit retry succeeds.
Choose the cutoff before introducing asynchronous work
An await between draining and disconnecting opens an interval in which additional eligible mutations can occur. A later disconnect could discard those new records, defeating the claimed cutoff. Keep acquisition and stopping synchronous, then start any asynchronous work with a batch already owned by the application.
The consumer owner must handle failures after that handoff. Observation can remain successfully stopped while persistence remains unfinished. Preserve the batch and error state for the selected recovery policy, and use HOLD when required acknowledgment is unresolved. The synchronous browser sequence does not establish persistent exactly-once processing across crashes.
Reconcile all five lifecycle cases
The table below summarizes the writer's executed browser results. Its primary counts agree with the supplied Chromium 144.0.7559.96 preparation probe; the local rerun used Chromium Headless Shell 141.0.7390.37 with Playwright 1.56.1 and Node.js v24.19.0. The agreement supports this controlled contract under those recorded conditions.
The final DOM column is sampled before element removal, after the deliberate later write. It therefore differs from the "2" present at the initial cutoff. “Second drain” means the immediate sample after the first lifecycle action and before that later write.
Lifecycle case | Ledger count | Routes and recorded old values | Live values at consumption | Final DOM | Second drain |
Immediate disconnect | 0 | None | None | "3" | 0 |
Drain, then disconnect | 2 | drain: "0", "1" | "2", "2" | "3" | 0 |
Ordinary callback, then disconnect | 2 | callback: "0", "1" | "2", "2" | "3" | 0 |
Drain without disconnect, then later write | 3 | drain: "0", "1"; callback: "2" | "2", "2", "3" | "3" | 0 |
Discard, unobserved write, re-observe | 1 | callback: "3" | "4" | "4" | 0 |
Every retained row also identifies the exact target, reports attributes, and names data-step. Those assertions are present in the runner even though the comparative table omits repetitive columns. The complete JSON keeps them available for inspection.
The table is an oracle authored around a known schedule, then compared with actual results. The fixture does not generate an expected count from the observed ledger and declare the two equal. It preserves returned data before assertions so a divergence remains inspectable.
The recording includes a UTC timestamp, runtime versions, engine, headless setting, platform, invocation, and per-case callback errors. Attach that JSON to a regression report with the matching source files. It explains which execution produced the ledger and whether the run finished; a count copied into a ticket without that context cannot establish that the intended fixture was exercised.
This is the same testing discipline behind explicit required-slot checks in JavaScript arrays: identify the required evidence before evaluating whether the implementation supplied it. Here the required population consists of eligible mutation records with particular fields and order.
Read all columns when diagnosing a failure. A correct count with the wrong old values can indicate a changed setup or schedule. Correct old values with the wrong route can expose premature callback delivery. A later callback after the intended stop indicates that registration did not end where the test expected.
The two-record cases also demonstrate why success needs more than absence of errors. Ordinary callback delivery and manual draining both satisfy the initial accounting requirement, but through different paths. Immediate discard can complete without throwing while failing that same requirement. Operational acceptance follows the declared policy and evidence, rather than the fact that a browser method returned normally.
Make the observer cleanup gate fail closed
The gate should answer the application contract, not merely report whether a browser launched. Before accepting the preserve policy, require the expected initial rows exactly once, correct identity and values, a stopped observer, no record for the controlled post-stop write, and no unacknowledged consumer failure.
Use the following decision vocabulary consistently:
Decision | Evidence or unresolved condition | Required action |
ACCEPT | Required records are accounted for once, the observer stops at the intended boundary, and the specified consumer outcome succeeds. | Approve the tested preserve policy under the recorded conditions. |
REPAIR | Disconnect-first cleanup loses required records; draining alone leaves observation active; rows duplicate or violate the declared scope. | Correct the responsible implementation and repeat the fixture. |
RERUN | Browser or runner versions, deterministic scheduling, or a completed execution record are missing. | Re-establish the controlled environment and obtain actual evidence. |
HOLD | The eligible record set, batch owner, or required downstream acknowledgment is undefined or unresolved. | Resolve the policy or handoff before claiming completeness. |
For failures caught by the harness, the runner sets a nonzero exit status and attempts to save the accumulated evidence. A missing dependency or an unwritable output file can prevent that JSON from being produced; inspect the process exit status and terminal error as well. The suite's successful status means all stated assertions passed, including the intentional discard negative control. It does not mean that every tested cleanup order implements preservation.
The harness labels caught assertion failures REPAIR and other caught setup or execution failures RERUN. HOLD remains an application decision about undefined requirements or unfinished acknowledgment. If the runner cannot produce a complete execution record, retain the available terminal output and rerun after correcting the environment. No browser assertion can automatically resolve those policy questions simply by observing a particular number of records.
When a required record has already been discarded, the operational response must acknowledge that gap. The final DOM value generally cannot reconstruct the missing notification history. A successful repair applied later establishes behavior for subsequent intervals; it does not retrospectively complete an earlier ledger.
Add ownership and repeated-cleanup controls
The writer executed three additional controls beyond the five-case preparation probe. In ownership, two independent observers watch the same single element. Observer A drains and stops; observer B still receives its original two records, then one later record. A remains at two, with no callback after its stop.
In repeat, three calls to endObservation() return [2, 0, 0], with a post-stop mutation between later calls. The ledger remains two rows. This checks that idempotent cleanup does not replay a batch or reopen observation.
The executed consumer-error control retains two records across the injected failure and repeated cleanup, then succeeds through explicit retry. Together these controls qualify the helper's local ownership and recovery behavior. They do not expand the five-case preparation evidence retroactively; they are separate results from the writer's run.
Assign lifecycle ownership and regression triggers
Assign policy, registration, consumption, and test ownership explicitly. One person may hold several responsibilities, but the handoff should remain clear enough that a stopped observer cannot be mistaken for completed downstream work.
The component owner chooses discard or preserve and explains the application consequence. That decision determines whether pending records are optional or required. It should be visible at the cleanup call site or in a narrowly scoped owner API, rather than inferred from whichever helper happened to be reused.
The observer owner defines the target, observation options, registration interval, and synchronous cutoff. This owner maintains the stopped state, prevents accidental duplicate consumption during repeated cleanup, and ensures that a new observation interval is represented explicitly.
The consumer owner defines normalization, batch retention, acknowledgment, and recovery after errors. If the sink changes from an in-memory array to another destination, this owner must specify what “processed” means and how partial success is represented. Browser cleanup cannot make that decision on the sink's behalf.
QA owns the literal expectations and the actual execution record. The runnable fixture, pinned dependencies, browser version, and ledger fields belong in the handoff. A screenshot of a final attribute value or a copied disconnect helper leaves too much of the acceptance contract unstated.
Revalidate when the attribute filter changes, old-value recording changes, the target or scope changes, the consumer gains side effects, lifecycle scheduling moves across an asynchronous boundary, or the supported browser baseline changes. Each change can affect eligibility, timing, or the meaning of consumption, so update the requirement before updating expected output.
Framework scheduling changes are a regression trigger even though no framework-specific lifecycle API is needed to demonstrate this contract. Preserve the native fixture as the small reference case, then evaluate the application's actual call boundary without assuming the framework supplies the same schedule.
Keep the separate problem of stale Fetch response ownership distinct in that handoff. This observer policy concerns eligible records queued before a local cutoff. Its evidence and acceptance rules are sufficient for that specific lifecycle question.
When a regression diverges, retain the failing ledger and version record. Diagnose whether the policy, schedule, implementation, or expectation changed. Replacing an unexpected result with the familiar count would remove the information needed to make the next decision responsibly.
Build stronger frontend lifecycle judgment
Reliable cleanup requires two questions to be answered separately: has registration ended, and has the application accounted for the notifications its policy requires? The two-write fixture makes both answers visible. Immediate disconnect discards pending evidence; synchronous drainage followed by disconnection gives the application a batch it can own and process.
The most useful habit is to state the evidence contract before choosing the API sequence. Name the target and filter, define the cutoff, share the consumer, and test the later write that must remain outside observation. Keep live readings labeled by when they were taken, and retain failed batches without claiming processing that did not occur.
For structured study of the supporting frontend foundations, consider Refonte Learning's FrontEnd Developer Program. The official page, checked October 17, 2026, lists a four-month duration at 10–12 hours per week, with HTML5/CSS3, JavaScript ES6+, React/Redux, APIs/AJAX, Git, accessibility, performance, and a capstone. Those topics provide foundations for reasoning about observable interface behavior, reviewing application boundaries, and communicating what a browser test actually establishes.
Carry the runnable fixture and its ledger policy into the next teardown review. A clean component boundary is one whose registration state, retained evidence, and remaining consumer responsibility can each be explained and checked.
