Hi! I'm Nick Jones. I design and build things for the web, make weird music, take photos, and live in beautiful California. I use this space to write, problem solve, and post things that don't belong anywhere else.

The case of the disappearing heading level

Nick Jones •

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:

  1. The editor never serializes the level (a schema problem), or
  2. 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 (what editor.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 where level:3 became nothing.
  • Null-prototype objects are a serialization landmine. JSON.stringify hides them; structured clone and the RSC serializer do not. Anything from ProseMirror (and plenty of other libraries) that hands you Object.create(null) will round-trip fine through JSON and then quietly fail to cross a Server Action, a postMessage, 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.

A little icon of a bookshelf

Deep time in California

Nick Jones •
post image

The thing that strikes me most about California on this trip is how tangible deep time feels here. Obviously things were happening in geological terms in what is now the eastern United States millions of years ago, too, but the denuded landscape out here—especially in the central valley up to the Mexican border—makes it all so apparent.

As we drove through Joshua Tree and looked out on the Mojave Desert, every curve of the road revealed a new alien landscape; boulders the size of houses in piles, others dropped alone onto the desert floor surrounded by acres of nothing. The sight lines making everything seem immense and somehow walkable at the same time. Every hill and mountain seems to be just a short walk away, but on closer examination is an hour’s hike over the horizon. Time just doesn’t matter in a place like this. In fact it simply doesn’t exist on a scale that a human can perceive. Row after row of mountains disappear on all sides into the vertigo-inducing distance.

In the desert we saw very few other humans, but at the Salton Sea we saw none. It’s really unnerving to stand in a place like that and be essentially alone. Here you are on the rim of what was a desert only seconds ago in geological terms. A blink later this is the shoreline of an inland sea peopled with camps, alive with talking and the smoke from fires. Every time you shut your eyes and open them again you’re in a new landscape. The Colorado Valley used to flood and recede in 1300 year cycles, like the planet drawing a chest full of air and slowly breathing it out. I imagine the Colorado River itself swelling and contracting, alternately sparing or flooding Lake Cahuilla. At some point today I had to just sit down.

The sheer size and scale of places like the Salton Sea or the Mojave Desert just have a way of making one feel small; or at least like there are forces acting on us all and on the planet that we know very little about, or certainly are not in control of.

A little icon of a bookshelf

Keep Apple weird

Nick Jones •
post image

The problem at Apple is that its CEO is super great at making money and not much else. His driving passion is…wait for it…stuff that makes money. And to someone like that, anything is worth pursuing and potentially abandoning. Remember when the $3,000 face computer was going to be THE THING™ and then it undersold and now you never hear about it anymore? Yeah. (Long thread ahead. Sorry.) 🧵

Steve Jobs was not a good person, at least not for a substantial chunk of his brief life. He treated his daughter like crap, used people to get what he wanted, and generally saw nothing wrong with any of it until much later. BUT. He was passionate about things, and was deeply committed to very few things. The big success story about Steve Jobs is actually pretty simple:

You need to get the rings marked “things I am passionate about” and “things I can get other people excited enough to repeatedly pay me for” to greatly overlap in your personal Venn diagram. His genius was in making that happen over and over. Then, he and his team would tweak a few knobs and do it again.

I think people tend to forget that there were some early “big swings” where Jobs and Co. missed terribly—like the iPod HiFi and the G4 Cube. But they rebounded quickly, and used those failures to get other things right—like the Mac mini and the HomePod. (Remember what I said earlier about being passionate about just a few things.)

Modern Apple doesn’t really do things that way. I’m hesitant to say that there’s no fire or drive there. I mean, Apple Silicon is a success story for the ages. But Cook-era Apple takes a success like Apple Silicon and turns it into a yearly rollout of processor bumped computers in identical cases, iPhone style. And what’s sad is I think Tim Cook thinks this is what Steve Jobs was doing.

I feel like Cook has tried really hard to emulate the Jobs laser focus, but only in these kinds of peripheral ways. It’s like Apple is now a cargo cult of its former self, with the refusal to experiment very deeply anywhere being the biggest indication of a pretty severe misunderstanding of the very playbook they claim to revere.

Again, look at the early “Jobs return” era of Apple. There is some wild shit going on there! It’s like a materials science class is happening in the lobby at Frog design. There’s titanium, and double shot rubber, and lenticular printing and all this wild stuff—but it’s all leading somewhere. There is deep experimentation but most of it is in the name of building a better consumer experience, because that’s what Steve Jobs cared about. The money was a happy, fortuitous accident.

Then Jobs dies, and it’s just in time for the ownership era to end. Computers are commodities, everything is a subscription. Software now is webpages. And here’s Tim Cook looking at what he’s inheriting, and he must be thinking that the worst that can happen is that he fucks it up, right? So how does he not fuck this up?

The difference between an excellent custodian and a visionary is that a visionary isn’t really all that concerned with prior art. “Great artists steal”, or so the quote goes. You take what you need and you make it better as you do it, and you release something like a MacBook Air. You get Intel to work triple overtime designing the first Mac SoC (irony!) and it’s so small it’s astonishing. You borrow, prod, push and pursue with laser focus.

I can’t get inside the head of the modern Apple. I don’t care about Siri or AI. I think most of this stuff is junk. I use Claude with frequency and I think it’s actually really amazing, but I like it as an application for my computer—not as the computer. I think a lot of what has made Apple great has been its (sometimes frustrating) tendency to be a little detached about things.

Remember “Rip, Mix, Burn”? Apple missed the wave of kids making shitty CDs of Staind songs they stole from Limewire on their parent’s Compaq Presarios with CD burners. And it really bothered Jobs. I think he felt like he should have seen it coming. So Apple does this huge campaign and they start putting CD-R drives in all their computers.

Thing is? I thought that was possibly the cringiest Jobs move of them all. Apple, to me, has always been about thumbing its nose and saying “why the fuck do you need 300 applications that all do your taxes? We have four, and they’re the four best ones.” Chasing some hype wave that will be gone in 18 months is just kind of sad and unbecoming.

It’s dumb to announce features before they’re baked. Especially when those features are only intended to surf some imaginary venture capital hype wave. But this seems totally within keeping with what I have observed to be Tim Cook’s passion, which is to make money and increase shareholder value. And to be clear with that as your guide you can do a TON of stuff! I mean, our whole world is based on the generation of capital and the creation of shareholder value and the execution of KPIs.

But at the risk of sounding like some hippie-dippy asshole, that’s not Apple. Part of the charm of Apple is making money hand over fist in a way that seems accidental. They’re the last company to not talk about money, to spot you the cab fare, to tuck the stickers in the box, to say “oh I have one right here” when the parts are missing. Apple is meant to be a paradox.

Apple is the most burned out wastoid you know explaining the Fermi paradox to you in complete and accurate detail on a van ride from Aspen. Apple is a guy from Berkeley taking a job at HP, hating it, but buying a house with his salary because he thinks you’re supposed to do that—but then having no furniture. Apple is the last bastion of whatever was left of the charm of Silicon Valley.

But like a lot of things we’d taken for granted it will be interesting to see how much is left of this in four years. As of right now, this entire Siri thing reeks of some third-rate Google drama from 2007. As I’ve pointed out before, it’s sad because it’s a business faux pas, yes, but it’s more sad because it’s so beneath Apple to be chasing this AI snake oil.

I mean, sure it’s a little overwrought, but with the world lapsing into “say it loud and say it proud, generating capital is the only worthwhile thing ever” mode, having at least one corporation out there that seemed to be following its bliss would be really nice. Naked capitalism is just…very gross. (To me. Like what you like. I guess. Sigh.)

Keep Apple weird—I guess that’s the punchline. Boy, that ship has sailed though, huh? First trillion dollar company. Yikes. Anyway.

A little icon of a bookshelf

Nothing for free

Nick Jones •

One of the things I’ve spent some time doing for companies and clients is trying to work around the “Apple tax”, i.e. the 30% cut Apple takes from the proceeds of things you sell on their platforms. And it’s not just me. Over the last five years or so I’ve seen lots of companies attempt to cargo cult their way into building “parallel platforms” to the Apple App Store.

I’ve been trying to calmly explain that 30% in exchange for one of the largest, most well-tended and poised-to-purchase audiences in history is probably a deal. Not to mention the built in marketing, accounting, and compliance Apple gives you and which you don’t have to build or maintain.

It’s especially a deal when you consider how much we’ve spent orienting our businesses around “free” stuff—like Google’s algorithm or TikTok—that have left us high and dry. If businesses had the dollars back that we’ve spent on SEO-optimized drivel and snake oil instead of high quality website content, or on videos of people doing dances instead of real, human stories about how people use our products and services, we would see how 30% is basically a steal.

No one is going to help you “earn” billions of dollars on a “free” platform like Google, TikTok, or Instagram. To me even the concept of huge, complex media strategies that involve those kinds of tools is somewhat ridiculous; these platforms are fickle and they build audiences based on patterns of addiction and attention seeking which are not sustainable. And when they go away, you have no recourse. You can’t sue TikTok for terms of service when they go dark on a Tuesday and you still had 40 videos in your queue of people doing dances to your insurance company’s jingle for “virality”.

With Apple you can at least point to a real contract, and real money changing hands—tangible things that might actually be helpful should you ever find you need to enforce the terms of your agreement. The Apple tax gives you a little skin in the game that likely makes middle managers at Apple think twice before they make sweeping changes to the App store. Meta (obviously) won’t think twice before they go and chase whatever hype wave they think will make them money, like, oh I don’t know, becoming the social network of record for the American fascist movement. They don’t owe you or your company shit. What’s more, they’ve proven that they don’t care even when the actually do have contracts with publishers.

Ceding huge swaths of your marketing and promotional budgets to other companies, who then sell ads based on the content you produce as a business, is utterly insane. Further, it’s really strange to feel like this needs to be explained to a class of business owners that considers itself to be the most savvy, the smartest, the best and the brightest in world history.

A little icon of a bookshelf

Social media is bad actually

Nick Jones •

I was one of the first 20,000 or so people to have Twitter. I remember being at SXSW shortly after its launch. Screens in the convention lobby were showing attendees’ tweets in real-time. Twitter employees were running around replacing dead and dying servers. It seemed like an important thing at the time.

I think we struggle to find a good purpose or higher meaning for anything we’re addicted to. In its time on earth, Twitter patted itself on the back as the enablers of the Arab spring, the #MeToo movement, and more. Any mass media platform could have done those things, but Twitter was the one that was there so they take the credit.

But not long after Twitter became part of our vernacular people started to wonder—the types of people who can usually be counted on to wonder—if it was good for us. What happens when the majority of the world is existing more or less in the public eye? And what does it say that so many people are doing it willingly? If I came to your house and told you that you had to share all the minutiae of our day with the world you would very likely be angry. But not us. We liked it. And then so many other apps came along whose very business models relied solely on our willingness to overshare.

I can look back over the last twelve years or so and tell you that for me it was not healthy. Relying on an external and unseen audience for approval, validation, and purpose is not only terrible for the human brain—but it’s well-known that it is. I’ve wrestled with who I am as a person, and whether or not the things I created were of value if I didn’t talk about them online. Like, this isn’t news to anyone, really. It’s frankly crazy that we put up with it for as long we did.

The issue at hand now is that all the usual suspects—the governments, the oligarchs, the bad-actors on a global scale, the fascists—are taking over the tools, as they always will. Mass media has a history of infiltration by one extreme cause or another, and social media isn’t different. You combine the interests of business with the interests of government and you get fascism. Companies like Twitter always talk like the Beatles until there are lawyers or senators in the rooom; they pitch themselves like wide-eyed, hippy-dippy solutions to all the world’s ills, but are more than happy to sellout at the first opportunity.

So when that makes you mad, and if you feel like you need social media badly enough, you can pack up and leave. But I would urge you to consider for a moment (talking to myself here, too) that you don’t really need social media. Yes, this sentiment is not new. Yes, I sound like someone’s English professor. But it’s also very true.

In a dozen years, I’ve made so few connections that I would not have been able to make in some other way through these platforms. For most people, things like Twitter are not the great enabler they claim to be. (They could be, but the money isn’t in that particular aspect of these businesses.) My favorite people on Threads, for example, live minutes away from me in the huge city I live in.

In 2025 I think I’d like to rely less and less on this stuff (social media), and stop getting caught up in this ridiculous routine of indignantly packing up all my data to move from app to app in a huff when said app’s handlers are revealed to be evil in some new and terrible way. I have enough problems.

A little icon of a bookshelf