fix(writekit): attr coercion stops erasing data, validate runs, undo coalesces

Three fixes driven by building a real consumer (cyrille studio) on 0.0.1, each
pinned by tests:

- `coerceAttrs` treated the spec as a whitelist: any attribute a block
  definition did not declare was silently deleted by the first
  `normalizeDocument` pass — and since normalization runs on load and consumers
  autosave, the erasure wrote itself back to storage. Coercion now fills
  defaults and keeps unknown keys verbatim. Parse rules build attrs explicitly,
  so pasted markup cannot smuggle keys through this path; the CRDT never calls
  coercion, so replica semantics are unchanged.

- `AttrSpec.validate` was consulted only by `validateDocument`, which nothing
  in the library calls — it looked like enforcement and was inert. A provided
  value failing `validate` now falls back to the declared default,
  deterministically (CRDT-safe given one spec) and loudly in dev.

- Undo recorded one entry per transaction — one keystroke per Ctrl+Z, and 200
  keystrokes evicted the entire earlier history. Plain typing in one block now
  coalesces within a 500ms window by concatenation, which preserves the replay
  invariant (`inverted` stays in application order, replayed reversed), counts
  as ONE entry against maxSize, and never merges across blocks, structural
  changes, or a foreign transaction (remote setDoc, undo/redo, selection-only
  moves interrupt the chain). `coalesceMs: 0` opts out.

Also: `component` in a block definition may now be a lazy loader
(`() => import('./Card.vue')`) — a registry imported for its schema (codecs,
tests, server-side normalizers) then carries no view graph; the view wraps the
loader in a cached async component on first render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 03:04:12 +07:00
parent ea96d720f2
commit cc93715c03
7 changed files with 367 additions and 15 deletions
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { HistoryEntry } from '../history';
import type { Step } from '../step';
import { createHistory } from '../history';
const caret = { type: 'text', anchor: { blockId: 'b1', offset: 0 }, focus: { blockId: 'b1', offset: 0 } } as never;
function typing(blockId: string, text: string): HistoryEntry {
return {
steps: [{ type: 'insertInline', blockId, offset: 0, content: [{ text, marks: [] }] } as Step],
inverted: [{ type: 'deleteText', blockId, from: 0, to: text.length } as Step],
selectionBefore: caret,
selectionAfter: caret,
};
}
function structural(blockId: string): HistoryEntry {
return {
steps: [{ type: 'removeBlock', blockId } as Step],
inverted: [{ type: 'insertBlock', node: { id: blockId, type: 'paragraph', attrs: {}, content: [] }, index: 0 } as never],
selectionBefore: caret,
selectionAfter: caret,
};
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe('history coalescing', () => {
it('merges a typing burst in one block into one undo press', () => {
const history = createHistory();
for (const ch of ['h', 'e', 'l', 'l', 'o']) {
history.record(typing('b1', ch));
vi.advanceTimersByTime(100);
}
const entry = history.undo()!;
expect(entry.steps).toHaveLength(5);
expect(history.canUndo()).toBe(false);
});
it('keeps the replay order: later keystrokes undo first', () => {
const history = createHistory();
history.record(typing('b1', 'a'));
history.record(typing('b1', 'b'));
const entry = history.undo()!;
// `inverted` stays in application order; undo replays it reversed, so the
// inverse of "b" must sit AFTER the inverse of "a".
expect(entry.inverted.map(step => (step as { to: number }).to)).toEqual([1, 1]);
expect(entry.steps.map(step => (step as { content: Array<{ text: string }> }).content[0]!.text)).toEqual(['a', 'b']);
});
it('starts a new group after the time window', () => {
const history = createHistory({ coalesceMs: 500 });
history.record(typing('b1', 'a'));
vi.advanceTimersByTime(600);
history.record(typing('b1', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
it('never merges across blocks', () => {
const history = createHistory();
history.record(typing('b1', 'a'));
history.record(typing('b2', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
it('never merges structural changes', () => {
const history = createHistory();
history.record(typing('b1', 'a'));
history.record(structural('b1'));
history.record(typing('b1', 'b'));
expect(history.undo()!.steps).toHaveLength(1);
expect(history.undo()!.steps).toHaveLength(1);
expect(history.undo()!.steps).toHaveLength(1);
});
it('breaks the chain on interrupt — a foreign transaction is a boundary', () => {
// A remote setDoc or an undo between keystrokes must not be spliced into
// one undo press with them.
const history = createHistory();
history.record(typing('b1', 'a'));
history.interrupt();
history.record(typing('b1', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
it('counts groups, not keystrokes, against maxSize', () => {
const history = createHistory({ maxSize: 2 });
// Two bursts of three keystrokes: two groups — both must survive.
for (const ch of ['a', 'b', 'c'])
history.record(typing('b1', ch));
vi.advanceTimersByTime(1000);
for (const ch of ['d', 'e', 'f'])
history.record(typing('b1', ch));
expect(history.undo()!.steps).toHaveLength(3);
expect(history.undo()!.steps).toHaveLength(3);
expect(history.canUndo()).toBe(false);
});
it('can be disabled outright', () => {
const history = createHistory({ coalesceMs: 0 });
history.record(typing('b1', 'a'));
history.record(typing('b1', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
});