The case of the disappearing heading level
This post is about building Clackpad, a note taking app for people who hate note taking apps.
A heading you set to H3 looks fine — until you refresh the page, and it’s an H1 again. This is the story of tracking that bug down through a TipTap editor, a React Query cache, and a Next.js Server Action, and the one-line root cause hiding at the very bottom: Object.create(null).
The symptom
The app is a notes editor (TipTap/ProseMirror on the front, Supabase behind a Next.js Server Action). The report was precise:
If a file contains an H1 and I restyle it to H3 it appears to persist, but reverts to H1 when the page is refreshed.
“Appears to persist, reverts on refresh” is the classic signature of something isn’t being saved. The obvious suspects: a debounce that never fires, a stale closure in the save handler, or a load path that overwrites with old data. All of them turned out to be innocent.
Establishing ground truth
Rather than guess, the first move was to look at what was actually stored. A quick query against the notes table:
select
count(*) as total_headings,
count(*) filter (where node->'attrs' ? 'level') as with_level
from headings;
-- total_headings: 19, with_level: 0
Zero of nineteen headings in the entire database had a level attribute. Not one. And in ProseMirror, a heading node with no level falls back to its schema default — which is 1. So every level-less heading renders as an H1 on load. That explained the visible “revert.”
But it raised a sharper question: why is the level missing from storage? Two possibilities:
- The editor never serializes the level (a schema problem), or
- The level is serialized but lost somewhere on the way to the database.
Ruling out the schema
A heading node’s JSON should look like {"type":"heading","attrs":{"level":3},...}. The stored nodes were {"type":"heading","content":[...]} — no attrs at all. In ProseMirror, Node.toJSON() includes attrs whenever the node type defines any attribute, so an entirely missing attrs looks like a schema with no level attribute.
So I built the exact schema the app uses, outside the app, and asked it directly:
import { getSchema } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
const schema = getSchema([StarterKit.configure({ codeBlock: false })]);
const node = schema.nodes.heading.create({ level: 3 }, schema.text('Hello'));
console.log(JSON.stringify(node.toJSON()));
// {"type":"heading","attrs":{"level":3},"content":[{"type":"text","text":"Hello"}]}
The schema was fine. getJSON() would include the level. So the loss had to be downstream.
A trace from keystroke to database
The only way to settle this was to watch the value at every hop. I added temporary logging at three points and reproduced the edit live:
- In
onUpdate(whateditor.getJSON()produces):attrs:{"level":3}✅ - In the debounced send (the buffer right before calling the Server Action):
hasLevel=true … attrs:{"level":3}✅ - Inside the Server Action (what the server actually received):
hasLevel=false,"heading","content"❌
There it was. The client serialized level:3, held level:3 in the buffer, handed level:3 to the Server Action call — and the server received a heading with no attrs at all. The data was being stripped in transit, by the Server Action boundary itself.
The root cause: null-prototype objects
Why would a serializer drop one specific key? It doesn’t — it drops the whole attrs object, and it does so because of what kind of object it is. ProseMirror builds node attributes with Object.create(null):
const doc = schema.nodeFromJSON({
type: 'doc',
content: [{ type: 'heading', content: [{ type: 'text', text: 'Hi' }] }],
});
console.log(Object.getPrototypeOf(doc.firstChild.attrs) === null); // true
console.log(Object.getPrototypeOf(doc.toJSON().content[0].attrs) === null); // true
Those attrs are null-prototype objects, and getJSON() preserves that prototype. JSON.stringify doesn’t care about prototypes, so every log along the way showed level:3 happily. But React Server Functions serialize their arguments with the RSC wire format, and that serializer treats a null-prototype object as “not a plain object.” In stock React you get a thrown error — “Only plain objects can be passed to Server Functions”. In this particular Next.js build, it didn’t throw; it just silently dropped the value.
So {type:"heading", attrs:Object.create(null){level:3}, ...} went in, and {type:"heading", ...} came out. The level — and, it turns out, image dimensions, code-block languages, and every other node attribute — vanished at the boundary.
The fix
The content needs to be plain old Object.prototype objects before it crosses into the Server Action. A JSON round-trip does exactly that, rebuilding every nested object with a normal prototype:
// ProseMirror builds each node's `attrs` as a null-prototype object
// (Object.create(null)), and editor.getJSON() preserves that. This Next.js's
// Server Action serializer silently drops non-plain objects, which would strip
// every node's attributes in transit (heading levels, image dimensions,
// code-block language, …). Round-tripping through JSON rebuilds everything with
// Object.prototype so the attributes survive the wire to updateNote().
function toPlainJson(value: Json): Json {
return JSON.parse(JSON.stringify(value)) as Json;
}
Applied at every spot where editor content crosses into a Server Action — the debounced autosave and the explicit Cmd-S save:
const content = toPlainJson(editor.getJSON() as Json);
qc.setQueryData(noteBodyKey(noteId), content);
flush({ content });
Verifying with data, not vibes
The acceptance test wasn’t “does it look right” — it was the same query that started the investigation. After the fix, one edit setting a heading to H3:
total_headings: 5, with_level: 5, levels: [3, 1, 1, 1, 1]
From 0/5 to 5/5, with the toggled heading correctly stored as level: 3. The level now survives a refresh.
Takeaways
- Look at the stored data first. “0 of 19 headings have a level” reframed the whole problem in one query and killed a half-dozen plausible-but-wrong theories about debounces and closures.
- Trace the value, don’t theorize about it. Logging
getJSON→ buffer → server at the same instant turned an unfalsifiable mystery into a single visible hop wherelevel:3became nothing. - Null-prototype objects are a serialization landmine.
JSON.stringifyhides them; structured clone and the RSC serializer do not. Anything from ProseMirror (and plenty of other libraries) that hands youObject.create(null)will round-trip fine through JSON and then quietly fail to cross a Server Action, apostMessage, or a Web Worker boundary. - A “this isn’t the framework you know” warning is worth believing. Stock React throws on this; this build dropped the data silently. The silent failure mode is what made the bug invisible until the very last hop.
A bug that looked like “headings won’t save” was really “null-prototype objects don’t survive Server Action serialization.” One JSON.parse(JSON.stringify(...)) at the boundary, and H3 finally stays H3.