The Meh Shirt’s Typographic Loudness Is the Joke: A Systems Reading of a Dismissive Word

The native garment preview shows large bold white lowercase meh. lettering on a dark shirt. No animal illustration is present. That is the entire design. The period is there. The word is lowercase. The type is heavy enough to read across a room. The shirt is dark, so the white lettering is the only event on the surface.

I keep coming back to the mismatch. Meh is a word that performs disengagement. It is the verbal equivalent of a shrug, a token of low-stakes indifference. Yet the design sets it in the typographic register of a statement: large, bold, centered, punctuated. The shirt does not whisper meh. It announces it. That contradiction is the whole object, and it is worth reading carefully rather than treating as a generic gag.

If you want the object itself, it is the Meh Funny Sarcastic Saying Introvert Dry Humor shirt from Quirky Cotton Collective. The listing title stacks several retail descriptors — funny, sarcastic, introvert, dry humor — but the visual observation is narrower: large bold white lowercase meh. on a dark garment. The title is marketing prose. The artwork is the evidence. I am reading the artwork.

Editor’s note: This publication and Quirky Cotton Collective share an owner.

What the typography actually does

Lowercase meh matters. Set in caps, MEH. would read as a shout, a demand, a piece of aggressive branding. Lowercase softens the word into something closer to a mutter. The period, though, pulls in the other direction. A period is a full stop. It closes the sentence. It refuses continuation. So the design holds two signals at once: the word is casual, the punctuation is final.

Bold weight and large scale add a third signal. Bold type is used for emphasis, for warnings, for headlines, for things the reader is supposed to notice. A bold, large, lowercase meh. is therefore not indifferent at all. It is a carefully staged performance of indifference. The shirt is doing work to look like it is not doing work. That is a recognizable move in dry humor, and it is also a recognizable move in engineering culture, where the most emphatic statements are sometimes delivered in the flattest possible register.

I am not claiming the designer intended a semiotic treatise. I am claiming the visual facts support this reading: the scale is large, the weight is bold, the case is lowercase, the punctuation is a period, the ground is dark, the ink is white. Those are observations, not interpretations. The interpretation is that the combination produces a specific effect — loud understatement — and that effect is the design’s actual content.

Why the missing animal matters

The observation explicitly notes that no animal illustration is present. That absence is worth stating because novelty shirts in this category often lean on a mascot: a sloth, a cat, a grumpy bear, some creature to carry the attitude so the wearer does not have to. This shirt has no mascot. The word has to carry everything.

That makes the typography load-bearing. There is no illustration to soften the message, no character to absorb the irony, no visual joke to explain the verbal one. The design is a single typographic event on a dark field. If the type were weak, the shirt would be nothing. Because the type is large and bold, the shirt works as a flat assertion. The absence of an animal is not a gap; it is a constraint that forces the lettering to do all the work.

I prefer it this way. Mascots tend to convert dry humor into cute humor. A sloth saying meh is a different object entirely — it is a joke about laziness, not a joke about register. The bare word keeps the register ambiguous, which is where the dry humor lives.

The difference between a slogan and a usable promise

Work-culture shirts usually make a promise. I fix things, I break things, Trust me, I’m an engineer — these are claims about identity or competence. They invite the reader to categorize the wearer. They are slogans in the ordinary sense: short, declarative, self-descriptive.

Meh. is not a promise. It is a refusal to promise. It does not tell you what the wearer does or believes. It tells you what the wearer is unwilling to muster. That is a different speech act. A slogan says here is my position. Meh. says I decline to have a position at this time. The period makes the decline final.

For technical readers, this distinction is familiar. A system that returns 200 OK with an empty body is not the same as a system that returns 204 No Content. Both decline to provide content, but one is a successful response with nothing to say, and the other is a formal statement that there is nothing to say. Meh. is closer to the second. It is not silence. It is a declared absence of enthusiasm, delivered with the confidence of a status code.

That is why the shirt reads as dry rather than rude. Rudeness would require engagement — an insult, a provocation, a demand. Meh. withholds engagement. It is the minimal viable response to a question the wearer did not want to answer.

Reading the period as a boundary

The period is the smallest element in the design and the one that changes it most. Without punctuation, meh is an interjection, a sound, a shrug rendered in letters. With a period, it becomes a sentence. A sentence has a speaker, a tense, and a terminus. The period says: this utterance is complete. Do not expect elaboration.

In editorial terms, that is a boundary. It is the typographic equivalent of closing a ticket with wontfix. The word alone might invite a follow-up question. The period forecloses it. The design is not asking for a conversation. It is stating a position and stopping.

I find this more interesting than the word by itself. A lot of novelty shirts rely on the reader to supply the joke. This one supplies the joke and then closes the thread. The period is the punchline’s punchline. It is the moment the design stops being friendly and becomes final.

What the shirt is not

It is not a pride variant. The observation notes no animal or pride variant. It is not a political statement. It is not a technical diagram. It is not a slogan about competence, productivity, or team culture. It is a single word in a specific typographic treatment on a dark ground.

That narrowness is a feature. The shirt does not try to be several things. It commits to one visual event and executes it at scale. In a category crowded with multi-element designs — text plus illustration plus border plus secondary joke — the single-word approach is a discipline. It is also a risk: if the type is not strong enough, there is nothing else to look at. Here the type is strong enough.

I am not going to claim the shirt improves anything, solves anything, or expresses a verified personality trait. It is a garment with a word on it. The word is meh. The word is large. The word is bold. The word is lowercase. The word has a period. That is the object, and the object is coherent.

How to verify the reading

If you want to check my interpretation against the artifact, look at the native garment preview on the listing. Confirm four things: the case is lowercase, the weight is bold, the scale is large relative to the shirt front, and the punctuation is a period. Then confirm the ground is dark and the lettering is white. Those six observations are the entire design. Everything else — the humor, the attitude, the cultural category — is inference from those six facts.

That is the right way to read a typographic shirt. Start with the visual facts. Then ask what the combination does. The answer here is that the design uses the visual grammar of emphasis to deliver a word that means the opposite of emphasis. The mismatch is the joke. The period makes it final. The missing animal keeps it clean. And the large bold lowercase lettering is what makes the whole thing legible from across a room, which is exactly the distance from which meh. is most effective.

How _edit_lock Works as a Pessimistic Lock Row in wp_postmeta — and Why Two Editors Can Still Collide After a Browser Crash

Two editors open the same post. One browser crashes. The other editor keeps working, saves, and then the first editor’s tab — restored from session — saves over the top. The lock was supposed to prevent this. It did not, because _edit_lock is not a lock in the mutual-exclusion sense. It is a timestamped freshness heuristic stored as ordinary postmeta, and the only thing standing between two writers is whether that timestamp is recent enough.

Symptom

The observable failure is not a database error or a REST 409. It is a silent overwrite. Editor A’s browser process dies at 10:04. Editor B opens the post at 10:06, sees no lock notice, edits, and saves. Editor A’s tab is restored at 10:09, the Heartbeat response no longer reports a lock error, and the editor saves. The post now contains A’s content. B’s changes are gone.

If you were watching the network panel, you would have seen the Heartbeat request from A’s tab stop at 10:04 and resume at 10:09. Between those points, wp_check_post_lock() returned false for B, and B was admitted legitimately by the read path.

Trace: the write path

The lock is written by wp_set_post_lock(). The function is short enough to read in full:

function wp_set_post_lock( $post ) {
    $post = get_post( $post );
    if ( ! $post ) {
        return false;
    }
    $user_id = get_current_user_id();
    if ( 0 === $user_id ) {
        return false;
    }
    $now = time();
    $lock = "$now:$user_id";
    update_post_meta( $post->ID, '_edit_lock', $lock );
    return array( $now, $user_id );
}

Two things matter here. First, the value is a single string formatted as timestamp:user_id. Second, the write is an unconditional update_post_meta() call. There is no compare-and-swap, no row-level constraint, no session token. Any caller with the post ID and a current user can overwrite the row. The function returns false only when the post does not exist or there is no current user (wp_set_post_lock()).

Because the value lives in wp_postmeta, it is visible to any SQL client and any WP-CLI session. That is the diagnostic surface:

wp post meta get 1234 _edit_lock
# 1729143840:7

Or directly:

SELECT meta_value
FROM wp_postmeta
WHERE post_id = 1234
  AND meta_key = '_edit_lock';

The first segment is a Unix timestamp. The second is a user ID. Nothing else is stored — no client identifier, no tab identifier, no heartbeat sequence number.

Trace: the read path

The lock is read by wp_check_post_lock(). The relevant portion of the source:

$lock = get_post_meta( $post->ID, '_edit_lock', true );
if ( ! $lock ) {
    return false;
}
$lock = explode( ':', $lock );
$time = $lock[0];
$user = isset( $lock[1] ) ? (int) $lock[1] : (int) get_post_meta( $post->ID, '_edit_last', true );
if ( ! get_userdata( $user ) ) {
    return false;
}
$time_window = apply_filters( 'wp_check_post_lock_window', 150 );
if ( $time && $time > time() - $time_window && get_current_user_id() !== $user ) {
    return $user;
}
return false;

Four conditions must all hold for the function to return a user ID:

  1. The _edit_lock meta value exists.
  2. The user ID parsed from the value (or falling back to _edit_last) resolves to a real user.
  3. The stored timestamp is within the last 150 seconds, filtered by wp_check_post_lock_window.
  4. The current user is not the lock holder.

If any condition fails, the function returns false — meaning “not locked” as far as the caller is concerned. The documentation states the return is false when the post does not exist, is not locked, the user with the lock does not exist, or the post is locked by the current user (wp_check_post_lock()).

The 150-second window is the entire enforcement mechanism. There is no other check. A lock older than 150 seconds is treated as absent, regardless of whether the editor’s browser is still open.

Trace: the refresh path

What keeps a live editor’s timestamp fresh is wp_refresh_post_lock(), which runs on the Heartbeat API. The function checks the lock and either reports a conflict or writes a new one:

$user_id = wp_check_post_lock( $post_id );
$user = get_userdata( $user_id );
if ( $user ) {
    $error = array(
        'name' => $user->display_name,
        'text' => sprintf( __( '%s has taken over and is currently editing.' ), $user->display_name ),
    );
    // ...
    $send['lock_error'] = $error;
} else {
    $new_lock = wp_set_post_lock( $post_id );
    if ( $new_lock ) {
        $send['new_lock'] = implode( ':', $new_lock );
    }
}
$response['wp-refresh-post-lock'] = $send;

Two details are worth noting. First, the function returns early without touching the lock if the current user lacks the edit_post capability for that post (wp_refresh_post_lock()). Second, when another user holds the lock, the response carries a lock_error with that user’s display name — this is the string the editor UI renders as the takeover notice.

When no other user holds the lock, the function writes a fresh timestamp via wp_set_post_lock() and returns it in the Heartbeat response. This is the mechanism that distinguishes a live editor from a dead one: the live editor’s tab keeps sending Heartbeat requests, and each one refreshes the timestamp. The dead editor’s tab stops, and the timestamp ages out.

Root cause: freshness, not exclusion

The collision after a browser crash is not a bug in the lock. It is the lock working as designed. The design is a freshness heuristic:

  • The write path does not prevent a second writer. It overwrites.
  • The read path does not enforce exclusivity. It checks whether the stored timestamp is recent.
  • The refresh path is the only thing that keeps a live editor’s timestamp recent.

When a browser crashes, the refresh path stops. The row remains in wp_postmeta with its last timestamp. For up to 150 seconds, wp_check_post_lock() still returns the crashed editor’s user ID, and a second editor is warned. After 150 seconds, the function returns false, and the second editor is admitted.

The first editor’s tab, if restored, resumes Heartbeat. At that point wp_check_post_lock() may return the second editor’s user ID, and the first editor sees the takeover notice. But the notice is informational. It does not prevent the first editor from saving. The save path does not re-check the lock in a way that blocks the write.

So the practical collision is not “the lock failed.” It is “the lock expired while the first editor still believed they held it.” The second editor was admitted legitimately by the read path. The first editor’s next Heartbeat is what surfaces the takeover — after the fact.

What the lock does not do

It is worth being explicit about the boundaries, because the failure mode is easy to misread:

  • It does not prevent concurrent writes at the database level. There is no unique constraint on _edit_lock, and update_post_meta() will happily overwrite an existing row.
  • It does not track sessions or tabs. The value is timestamp:user_id. Two tabs for the same user share the same lock identity.
  • It does not distinguish a crash from a slow save. Both leave a timestamp that ages out on the same schedule.
  • It does not block a save. The editor UI may show a notice, but the REST or admin-ajax save path does not reject the request based on lock state.

The lock is advisory. It is a coordination hint rendered in the editor UI, backed by a timestamped postmeta row and a 150-second window.

Fix: what you can actually tune

If your team sees frequent post-crash collisions, the tuning point is the window filter:

add_filter( 'wp_check_post_lock_window', function( $window ) {
    return 300; // seconds
} );

Widening the window trades collisions for longer lockouts after genuine crashes. A 300-second window means a crashed editor’s lock persists for five minutes, during which a second editor is warned and cannot take over without an explicit action. That is the trade-off, and it should be stated as one rather than presented as a fix.

Narrowing the window does the opposite: faster recovery after a crash, more frequent collisions if Heartbeat is delayed. If your Heartbeat interval is longer than the window, live editors will appear to have stale locks. That is a configuration mismatch, not a lock failure.

For self-maintained installs, the lock row is also directly clearable:

wp post meta delete 1234 _edit_lock

This is safe when you have confirmed the lock holder is not actively editing. It is not safe as a routine operation, because it removes the only signal the editor UI has that another user is present.

Verification: a diagnostic sequence

When a collision is reported, the useful sequence is:

  1. Read the row. wp post meta get <post_id> _edit_lock. Note the timestamp and user ID.
  2. Compare the timestamp to now. If now - timestamp > 150 (or your filtered window), the lock is already inert. The collision happened because the window lapsed, not because the lock was ignored.
  3. Resolve the user ID. wp user get <user_id>. If the user no longer exists, wp_check_post_lock() returns false regardless of timestamp — the lock is treated as absent.
  4. Check the window filter. If a plugin or theme has modified wp_check_post_lock_window, the effective window is not 150 seconds. Grep for the filter name in wp-content.
  5. Check the Heartbeat response. In the browser DevTools Network panel, filter for admin-ajax.php with action=heartbeat. The response body contains a wp-refresh-post-lock key. If it contains lock_error, another user holds the lock. If it contains new_lock, the current user just refreshed it.

That sequence distinguishes the three failure modes: a lock that expired (timestamp too old), a lock that was ignored (user ID missing), and a lock that was never written (no row).

Why the row lives in wp_postmeta

The choice of wp_postmeta as storage has a side effect that matters on sites with a persistent object cache. Every wp_set_post_lock() call is an update_post_meta() call, which invalidates the post’s cache entry. On a busy editorial site, Heartbeat-driven lock refreshes can generate a steady stream of cache invalidations for posts that are not actually being saved.

The Presence API feature plugin addresses this by moving post locks out of postmeta and into a presence table, so that refreshing a lock no longer invalidates cached post queries on sites with a persistent object cache (Make WordPress Core: Presence API). The same source notes that locks keep working when presence recording is switched off. This is a structural change to where the lock lives, not a change to the freshness-window behavior that causes the crash collision.

It is worth being precise: the Presence API is described in that source as a feature plugin, not as shipped core behavior. The _edit_lock postmeta row is what current core uses.

Related operational notes

The Heartbeat-driven lock refresh has been reported to keep auth-check requests firing on post edit screens even when wp_auth_check_load is removed. In a 2014 trac mailing list thread, a reporter described the auth-check still firing from a combination of post.js and heartbeat.js, likely driven by the refresh-post-lock path (wp-trac mailing list, February 2014). That report is old and should not be treated as a description of current behavior, but it illustrates that the lock refresh is entangled with other Heartbeat-driven admin behavior.

If you are debugging a new install that reports “nothing found” on the front end while the editor shows content, that is a different failure mode with a different diagnostic path. The relevant sequence is covered in What to Fix First When a New WordPress Site Says Nothing Found.

FAQ

Does the lock prevent two editors from saving at the same time?
No. The lock is advisory. The save path does not reject a request based on lock state. The editor UI may show a notice, but the write proceeds.

What is the default lock window?
150 seconds, applied via the wp_check_post_lock_window filter. The default is stated in the wp_check_post_lock() source.

Why does the lock persist after a browser crash?
Because the row is written to wp_postmeta and is only removed or refreshed by a subsequent request. A crashed browser sends no further requests, so the row remains until the window lapses or another process clears it.

Can I clear a stale lock manually?
Yes, with wp post meta delete <post_id> _edit_lock. Confirm the lock holder is not actively editing first, because deleting the row removes the only signal the editor UI has that another user is present.

Does the lock work across multiple tabs for the same user?
The lock identity is the user ID, not the tab. Two tabs for the same user share the same lock state, and wp_check_post_lock() returns false when the current user is the lock holder.

What happens if the lock holder’s user account is deleted?
wp_check_post_lock() returns false when get_userdata() fails for the parsed user ID. The lock is treated as absent regardless of the timestamp.

Is the lock stored anywhere else?
In current core, no. The Presence API feature plugin moves locks into a presence table, but that is not shipped core behavior as of the retrieved source.

Why Dynamic Block Output Leaks into get_the_excerpt(): do_blocks(), the excerpt_remove_blocks Filter, and the Parsed Block Tree Your Archive Cards Render

Your archive card shows a Latest Posts list where the excerpt should be. Or a Query Loop. Or a related-posts grid. The post has no manual excerpt, so WordPress generated one, and somewhere in that generation a dynamic block’s render_callback ran and its output landed in the card.

This is not a bug in excerpt_remove_blocks(). It is the expected result of render_block() executing a dynamic block during excerpt generation. The allowlist is the control surface, and the fix belongs there.

Symptom

An archive template calls the_excerpt(). The post has an empty post_excerpt. The card displays markup that belongs to a dynamic block: a list of recent posts, a query loop, a server-rendered widget. The post’s own prose is missing or truncated around it.

Two conditions produce this:

  1. The dynamic block’s name is in the allowed blocks list that excerpt_remove_blocks() uses.
  2. The post has no manual excerpt, so wp_trim_excerpt() generates one from the content.

If the post has a manual excerpt, get_the_excerpt() returns it and no block parsing happens. That is the cheapest diagnostic: set a manual excerpt on the post and reload. If the leak disappears, you are on the auto-generation path.

Trace

get_the_excerpt() returns the post’s post_excerpt value after applying the get_the_excerpt filter. The function source is short:

return apply_filters( 'get_the_excerpt', $post->post_excerpt, $post );

When post_excerpt is empty, wp_trim_excerpt() generates one from the content. That function applies the_content filters, and do_blocks() is attached to the_content. do_blocks() parses dynamic blocks out of post_content and re-renders them.

But the excerpt path does not simply call do_blocks() on the raw content. It calls excerpt_remove_blocks(), which parses blocks out of the content string and renders those appropriate for the excerpt. The function’s own description: “As the excerpt should be a small string of text relevant to the full post content, this function renders the blocks that are most likely to contain such text.”

Inside excerpt_remove_blocks(), the loop checks each parsed block’s blockName against an allowed list. If the name is in the list, the function calls render_block( $block ) and appends the result to the output.

render_block() constructs a WP_Block from the parsed block and returns the result of its render() method:

$block = new WP_Block( $parsed_block, $context );
return $block->render();

For a dynamic block, render() invokes the block’s render_callback. That callback runs during excerpt generation. Its output is now part of the excerpt string.

Root cause

The allowed blocks list is filterable through excerpt_allowed_blocks:

$allowed_blocks = apply_filters( 'excerpt_allowed_blocks', $allowed_blocks );

The default list includes inner blocks such as core/paragraph, core/heading, core/list, core/quote, and wrapper blocks such as core/columns, core/column, and core/group. The retrieved source lists these defaults but does not enumerate every dynamic block that a plugin or theme may add.

A dynamic block enters the excerpt in one of two ways:

  1. Its name is in the default allowed list.
  2. A site added it through excerpt_allowed_blocks.

The second is the common case for custom blocks. A developer wants a block’s text to appear in excerpts, adds the block name to the filter, and does not account for the fact that render_block() will execute the block’s render_callback during excerpt generation.

There is a documented hazard. The excerpt_remove_blocks() source warns that if a dynamic block is added to the allowed list, it must not generate another excerpt, because that causes an infinite loop. The Gutenberg commit that introduced the stripping approach includes a test comment explaining the mechanism: when dynamic blocks generate an excerpt, they can cause an infinite loop if that block is part of the post’s content. wp_trim_excerpt() applies the_content, which has do_blocks attached, trying to render the block, which again attempts to return an excerpt of that post.

The commit added strip_dynamic_blocks() and filters to strip dynamic blocks from excerpts before the_content is applied. That is a proposed implementation, not necessarily what shipped in core. The current core function is excerpt_remove_blocks(), and its allowlist is the control surface.

Fix

Remove the dynamic block from excerpt_allowed_blocks. If the block must contribute text to an excerpt, render that text through a path that does not call get_the_excerpt() again.

First, find the registrations. Grep the codebase for the filter name:

wp grep excerpt_allowed_blocks --all

Or with standard tools:

grep -rn "excerpt_allowed_blocks" wp-content/

For each callback, check which block names it adds. Compare those names against the dynamic block registry. A block is dynamic if it has a render_callback or a render file in its block.json. You can list registered block types with WP-CLI:

wp block-type list --fields=name,title

That command lists registered block types. To check whether a specific block is dynamic, inspect its registration in code or query the block type registry in a short script. The registry stores the block type object; a dynamic block has a non-null render_callback.

If the block should not contribute to excerpts, remove it from the filter. If you cannot edit the plugin that added it, remove it at a later priority:

add_filter( 'excerpt_allowed_blocks', function( $allowed ) {
    return array_diff( $allowed, array( 'my-plugin/latest-posts' ) );
}, 20 );

If the block must contribute text, do not let its render_callback call get_the_excerpt() or wp_trim_excerpt(). Read from a field or attribute that does not re-enter excerpt generation. For example, a block that stores a summary in post meta should read that meta directly:

function my_plugin_summary_block_render( $attributes, $content, $block ) {
    $post_id = $block->context['postId'] ?? get_the_ID();
    $summary = get_post_meta( $post_id, 'my_plugin_summary', true );
    if ( '' === $summary ) {
        return '';
    }
    return '

' . esc_html( $summary ) . '

'; }

This callback does not call get_the_excerpt(), so it cannot recurse. It also does not render post-derived content that belongs to another query.

For posts that use dynamic blocks near the top of the content, a manual excerpt is the cheapest reliable fix. get_the_excerpt() returns post_excerpt before any block parsing happens. Set a manual excerpt on those posts and the auto-generation path is bypassed entirely.

Verification

Confirm the leak is gone and the fix did not break the block’s front-end output.

First, check the excerpt directly. In a WP-CLI shell or a temporary script:

wp eval 'echo get_the_excerpt( 123 );'

Replace 123 with the post ID. The output should contain the post’s prose and not the dynamic block’s markup.

Second, check the front-end post. The dynamic block should still render where it belongs. The excerpt filter only affects excerpt generation, not the_content.

Third, if you removed the block from excerpt_allowed_blocks, verify the filter is no longer adding it. You can log the allowed list during a request:

add_filter( 'excerpt_allowed_blocks', function( $allowed ) {
    error_log( print_r( $allowed, true ) );
    return $allowed;
}, 999 );

Check the debug log for the block name. If it is absent, the removal worked.

Fourth, test the infinite-loop hazard. If you have a dynamic block that generates an excerpt, create a post containing that block, leave the excerpt empty, and request the post. If the request hangs or exhausts memory, the block is re-entering excerpt generation. Remove it from the allowed list.

Audit checklist

  • Grep for excerpt_allowed_blocks in plugins, themes, and mu-plugins.
  • For each added block name, check whether the block is dynamic (has a render_callback or render file).
  • For each dynamic block in the list, check whether its render_callback calls get_the_excerpt(), wp_trim_excerpt(), or the_excerpt().
  • If it does, remove the block from the list or change the callback to read from a non-recursive source.
  • For posts with dynamic blocks near the top, set manual excerpts.
  • Verify with wp eval 'echo get_the_excerpt( $id );' and check the front-end block output.

The parsed block tree your archive cards render is the same tree do_blocks() uses. The difference is which blocks excerpt_remove_blocks() chooses to render. Control that list, and the leak stops.

The Small Lowercase ‘meh.’ Is the Whole Design: Reading Scale and Understatement on the Meh Shirt

Editorial note: This publication and Quirky Cotton Collective share an owner.

The garment preview shows a dark shirt with very small, centered lowercase lettering: meh. That is the entire graphic. No animal, no mascot, no flag, no secondary line of copy. The period is present. The letters are lowercase. The mark sits in the middle of the chest area at a scale that reads as a whisper rather than a statement.

Most slogan shirts scale their type to be legible across a room. This one does the opposite. The design’s argument is carried by how little it says and how quietly it says it. That is worth reading closely, because the scale is not an accident of production — it is the joke.

What the artwork actually shows

From the native garment preview: very small, centered lowercase meh. lettering on a dark shirt. No animal illustration is present. The shirt type is minimal small lowercase typography, with no animal or pride variant. The retail title frames it as “Meh Funny Sarcastic Introvert Minimalist Saying,” but the printed artwork itself is just the word and the period.

I want to be precise about what that observation supports and what it does not. It supports a description of the graphic: scale, case, punctuation, placement, absence of illustration. It does not tell us how the shirt wears, how it fits, how the ink holds up, or what any buyer thinks of it. Those are separate questions, and I have no evidence for them here. The design is the object under discussion.

Why the period matters

A lowercase meh without punctuation is an interjection. It trails off. It is the sound of a shrug rendered in letters. Add the period and the word becomes a sentence — a complete, closed statement. The period is the smallest possible unit of finality. It says: this thought is finished, and it was not worth more than four letters.

That is a different rhetorical move than the standard sarcastic tee. Most sarcasm on apparel works by escalation — bigger type, sharper phrasing, an exclamation point doing the emotional labor. This design works by de-escalation. The period is the only emphatic mark on the shirt, and it is doing the opposite of emphasis. It is closing the door quietly.

I read the period as the design’s load-bearing element. Remove it and the shirt becomes a mood. Keep it and the shirt becomes a verdict. That is an interpretation, not a claim about the designer’s intent, but it is the interpretation the artwork most naturally supports.

Scale as the second joke

The lettering is very small. On a dark shirt, small type at chest center does not announce itself from across a room. It rewards proximity. You have to be close enough to read it, which means the shirt is not performing for a crowd. It is performing for one person at conversational distance.

That is a coherent choice for a word that means indifference. A large MEH would be a contradiction — shouting about not caring. A small meh. is consistent. The scale matches the sentiment. The design does not ask to be noticed; it asks to be read, and only if you are already close.

There is a practical consequence to this that is worth naming plainly: small centered type on a dark ground is a legibility decision, and legibility depends on contrast, print method, and viewing distance. I am not making claims about any of those for this specific garment, because the preview does not establish them. What the preview does establish is the compositional intent — the type is deliberately subordinate to the shirt, not the other way around.

The introvert framing and what it does not prove

The retail title includes “Introvert,” and related search terms include “meh introvert shirt” and “meh sarcastic shirt.” That tells us something about how the listing is categorized and what queries it is meant to answer. It does not tell us that introverts buy it, that it represents introversion, or that the word meh has any necessary relationship to introversion as a concept.

I would resist the easy reading. Meh is not a personality type. It is a response. The shirt does not say “I am an introvert.” It says “meh.” Those are different speech acts. One is a claim about identity; the other is a reaction to a situation. The design is closer to the second, and the period reinforces it — a reaction that has concluded, not an identity that is being declared.

If the shirt reads as introvert-coded, that is a function of context — the listing, the search terms, the cultural association between understatement and reserve — not of anything printed on the fabric. The printed content is four letters and a period. Everything else is supplied by the reader.

Minimalism as a constraint, not a style

It is tempting to file this under “minimalist” and move on. But minimalism here is not an aesthetic preference layered on top of a message. It is the message’s only viable form. A word that means low engagement cannot be delivered with high production values. The design has to be as uninvested as the word. Small lowercase type with a period is the minimum viable execution of the idea.

That constraint explains the absence of an animal. A mascot — a sloth, a cat, a cartoon face — would add affect. It would make the shirt cute, or wry, or relatable in a way the word does not authorize. The observation that no animal illustration is present is not a gap in the design. It is the design holding its line. The shirt refuses to give you anything to coo at.

The same logic rules out a pride variant. A rainbow meh would attach the word to a cause, and the word does not belong to a cause. It belongs to a mood. The shirt’s restraint is what keeps it from becoming a different kind of object.

What a slogan shirt can and cannot promise

Technical readers spend a lot of time around language that has to be precise — error messages, commit messages, status codes, incident summaries. A shirt like this is a small vacation from that precision. It is a word that means “no strong signal,” worn on purpose.

But it is worth being clear about what the shirt is not. It is not a statement about work culture, burnout, or boundaries. It is not a diagram joke. It is not a security posture. It is a word. Reading it as a manifesto would be overreading it in exactly the way the design avoids.

The useful observation is narrower and, I think, more interesting: the shirt demonstrates that understatement scales down. Most apparel graphics compete for attention. This one competes for nothing. It is a small, closed, lowercase sentence on a dark ground, and it is complete.

The object

The shirt is the Meh Funny Sarcastic Introvert Minimalist Saying from Quirky Cotton Collective. The artwork is the small centered lowercase meh. and nothing else. If you want a shirt that says one thing quietly and then stops, that is what this is. If you want a shirt that explains itself, this is not that shirt — and the period is the reason.

The design’s discipline is the point. Four letters, one period, centered, small. It does not ask for a reaction. It has already had one, and it is done.

The Periodic Table Pun on ‘I Wear This Shirt Periodically’ Is Not a Spelling

Editorial note: This publication and Quirky Cotton Collective share an owner.

The artwork reads I WEAR THIS SHIRT PERIODICALLY, and beneath the sentence sit four tiles styled after the periodic table. Each tile carries an atomic number and a symbol: 53 I, 74 W, 22 Ti, 16 S. The symbols are I, W, Ti, S. That sequence does not spell the word periodically. It spells IW TiS if you read the symbols as letters, which is not a word. The joke is not a hidden spelling. The joke is that the tiles are a visual grammar for the word periodically, and the word periodically is doing two jobs at once.

This is the kind of design that rewards a second look, because the first look produces a small category error. A reader who knows the periodic table sees element tiles and expects a chemical message. A reader who knows the sentence sees a pun about wearing a shirt sometimes. The two readings do not resolve into a single decoded phrase. They sit next to each other, and the friction between them is the point.

What the tiles actually say

The four tiles are not a rebus for periodically. They are a visual quotation of the periodic table’s format: atomic number, symbol, and the implicit suggestion of an element name. The numbers 53, 74, 22, and 16 correspond to iodine, tungsten, titanium, and sulfur. The symbols I, W, Ti, and S are the conventional chemical symbols for those elements. The design does not ask you to pronounce them as a word. It asks you to recognize the format and then notice that the format has been repurposed for a sentence about laundry rotation.

That distinction matters because a lot of chemistry-themed merchandise treats the periodic table as a substitution cipher. You spell a word by hunting for element symbols that happen to match the letters you need. Sometimes the result is clever. Sometimes it is strained. This design does not attempt that trick. It uses the table as a visual idiom, not a codebook. The tiles are there to signal chemistry and to make the word periodically land as a technical term borrowed for a mundane confession.

I read the tile arrangement as a deliberate refusal of the spelling game. If the designer had wanted a literal element spelling, the tiles would need to form a recognizable word. They do not. What they form is a visual rhyme with the periodic table itself, and the sentence above them supplies the meaning. The tiles are decoration with a specific referent, not a puzzle with a solution.

The pun works because ‘periodically’ is already a frequency claim

The sentence I wear this shirt periodically is funny for a reason that has nothing to do with chemistry. Periodically means at intervals. It is a word you use when you want to avoid committing to a schedule. I wear this shirt periodically is a softer statement than I wear this shirt often or I wear this shirt every week. It implies recurrence without promising regularity. The shirt is in the rotation, but the rotation is not documented.

Then the chemistry context arrives and adds a second meaning. In chemistry, periodic refers to the periodic table, which is organized by recurring patterns in elemental properties. The word periodically can point to both meanings at once: occasionally, and in the manner of the periodic table. The design does not explain this. It simply puts the sentence and the tiles in the same frame and lets the reader supply the connection.

That is a more sophisticated joke than a simple label. It assumes the reader knows what the periodic table looks like and knows what periodically means in ordinary speech. The humor comes from the collision of those two knowledges. If you have only one of them, the shirt still reads as a sentence. If you have both, the sentence becomes a small mechanism.

Why the tile symbols are not a spelling

I want to be precise about this because the visual observation is easy to misstate. The tiles show 53 I, 74 W, 22 Ti, and 16 S. The symbols are I, W, Ti, and S. If you concatenate them, you get IW TiS, which is not periodically. You cannot rearrange them into periodically without adding letters that are not present. The design is not a cryptogram. It is a set of four element tiles that happen to sit under a word that also has a chemistry meaning.

This is worth saying plainly because a casual description might call it a periodic table spelling of the sentence. It is not. The sentence is spelled out in ordinary letters above the tiles. The tiles are a separate visual element. They do not encode the sentence. They comment on it.

That comment is structural. The periodic table is a grid of recurring properties. The word periodically describes recurrence. The tiles make the recurrence visible as a pattern. The shirt is not claiming that iodine, tungsten, titanium, and sulfur have anything to do with each other in a chemical reaction. It is borrowing the table’s visual authority to make a joke about wearing a shirt sometimes.

The difference between a slogan and a usable promise

Technical readers spend a lot of time around terse messages. Error codes, status labels, commit messages, warning signs. A good terse message is a contract: it tells you what happened, what to expect, or what to do. A bad terse message is a slogan: it sounds authoritative but does not constrain anything.

I wear this shirt periodically is closer to a slogan than a contract, and that is why it works as a shirt. It is a statement about the wearer’s behavior that cannot be falsified in any useful way. If you wear it once, you have worn it periodically. If you wear it every day, you have also worn it periodically. The word is elastic enough to cover almost any pattern. The joke is that the sentence sounds like a specification but functions as a shrug.

I find that distinction useful when I look at work-culture objects. A shirt that says I fix bugs is a slogan. A shirt that says I will not deploy on Friday is closer to a policy. The periodic table shirt is in the slogan category, but it is a self-aware slogan. It knows that periodically is doing vague work, and it leans into the vagueness by pairing it with the most rigid visual system in science. The periodic table is all about predictable recurrence. The sentence is all about unpredictable recurrence. The tension is the joke.

Reading the design without overreading it

There is a temptation to treat a shirt like this as a statement about identity. I am a chemist. I am a science person. I wear this shirt periodically. That reading is available, but it is not required by the artwork. The design does not name a profession. It does not include a lab coat, a flask, or a molecular diagram. It uses the periodic table as a visual reference and a word that happens to have a technical double meaning. The rest is up to the wearer.

What I appreciate about the design is that it does not try to do too much. It has one joke, and it commits to it. The sentence is legible. The tiles are recognizable. The connection between them is not explained, which means the shirt rewards people who already know the reference and remains a plain sentence for everyone else. That is a reasonable design strategy for a garment that has to work in a grocery store and a conference hallway.

If you are looking for the shirt, it is listed as I Wear This Shirt Periodically Funny Chemistry Science Pun from Quirky Cotton Collective. The listing title uses the phrase i wear this shirt periodically, which is also the search term that describes the joke. The artwork is the thing worth looking at, though. The title is just a label. The tiles are the argument.

The small pleasure of a diagram joke

Diagram jokes are a specific genre. They work when the diagram is familiar enough that you recognize the format instantly, and when the content placed inside the format is slightly wrong. The periodic table is one of the most recognizable diagrams in science. Putting a sentence about shirt-wearing next to it is a mild violation of expectations. The tiles do not belong there. That is why they are funny.

The design does not push the violation very far. It does not replace the element names with punchlines or rearrange the table into a face. It simply places four tiles under a sentence and lets the format do the work. The restraint is part of the appeal. A louder version of this joke would be exhausting. This version is quiet enough to wear.

I do not know whether the designer intended the periodically double meaning to be the primary joke or whether the tiles were added to justify the word. It does not matter. The object as it exists has both elements, and the relationship between them is legible. The sentence says one thing. The tiles say another. The shirt is the space between them.

That is a useful thing to notice about visual language. A design can be funny without being a puzzle. It can be clever without being a code. The periodic table shirt is not asking you to solve it. It is asking you to recognize it. The recognition is the reward.

Why Your Custom Post Type Is Missing from the REST Search Endpoint: WP_REST_Search_Handler, the wp_rest_search_handlers Filter, and the post/page Default

Your custom post type has a working REST route. GET /wp-json/wp/v2/your-cpt returns a tidy collection. The block editor loads it. Then you query GET /wp-json/wp/v2/search?search=quarterly and get back posts, pages, maybe a category term, and nothing from your CPT. The instinct is to reach for wp_rest_search_handlers and register a handler. That instinct is usually wrong, and acting on it can duplicate results rather than fix them.

The /wp/v2/search endpoint does not enumerate post types directly. It delegates to a registry of search handlers. One of those handlers, WP_REST_Post_Search_Handler, already covers every public post type that is exposed to REST. If your CPT is missing, the problem is almost always in the post type registration flags, not in the handler list.

Symptom

A request to the search endpoint returns results whose subtype values are post and page, but never your CPT slug. The same CPT appears correctly at its own collection route. The block editor’s search panel, if it uses the search endpoint, also omits it.

Two separate registries are involved, and conflating them is the source of most wasted debugging time:

  • The handler list. A site-wide array of WP_REST_Search_Handler instances, filtered by wp_rest_search_handlers. The documentation states the default is only a handler for posts. That handler’s type is post.
  • The subtype list. Built once inside WP_REST_Post_Search_Handler::__construct() from get_post_types( array( 'public' => true, 'show_in_rest' => true ), 'names' ), minus attachment.

The handler list is global and rarely needs changing. The subtype list is built once, when the default post search handler is instantiated during REST route registration, not per request. This is exactly why a register_post_type() call on a late or conditional hook can miss the list. A CPT that is absent from search results but present at its own route points at the flags, not the handler list.

Trace

Start by reading the constructor of the default post search handler. The relevant lines are short:

public function __construct() {
    $this->type = 'post';
    // Support all public post types except attachments.
    $this->subtypes = array_diff(
        array_values( get_post_types( array( 'public' => true, 'show_in_rest' => true ), 'names' ) ),
        array( 'attachment' )
    );
}

Two flags gate inclusion: public and show_in_rest. Both must be true. The register_post_type() documentation is explicit that show_in_rest controls whether the post type is included in the REST API, and that public defaults to false. A CPT registered with only show_in_rest => true and no public argument will not appear in the subtype list.

When a search request arrives with subtype any, search_items() replaces the requested post types with the handler’s subtype list:

if ( in_array( WP_REST_Search_Controller::TYPE_ANY, $post_types, true ) ) {
    $post_types = $this->subtypes;
}

That is the mechanism. If your CPT is not in $this->subtypes, it is not queried. The handler then runs a WP_Query with 'post_status' => 'publish'. A correctly registered CPT whose entries are all drafts will also appear missing. Separate the registration question from the status question before changing code.

The search results schema lists subtype values as post, page, category, and post_tag. That list is illustrative of core defaults, not exhaustive of every handler a site may register. The type parameter on the endpoint defaults to post, which is why a bare search request without a type or subtype argument behaves as a post search.

Root cause

The default post search handler already enumerates every public, REST-exposed post type. Adding a custom handler to wp_rest_search_handlers to fix a missing CPT is likely to duplicate results, because the default handler is already covering the same post type when the flags are set correctly. The filter is for object types the post handler cannot represent, not for post types it already covers.

The wp_rest_search_handlers filter is applied in create_initial_rest_routes(), which registers the default REST API routes. Each handler instance passed through the filter must extend WP_REST_Search_Handler. That is a real extension point, but it is not the first place to look when a CPT is absent.

Two failure modes produce the same symptom:

  1. Registration flags. public is false or omitted, or show_in_rest is false or omitted. The CPT is not in the subtype list, so the default handler never queries it.
  2. Status. The CPT is registered correctly, but all its entries are drafts, pending, or otherwise not publish. The handler queries with post_status => 'publish', so nothing returns.

A third, less common case: the CPT is registered on a hook that runs too late for the handler’s constructor, or the registration is conditional on a context that is not true during REST requests. The constructor runs when the handler is instantiated, which is during REST route registration. If your register_post_type() call is hooked to something that has not fired by then, the subtype list will not include it.

Fix

Diagnose in order. Do not start with the filter.

1. Inspect the registered post type object

Use WP-CLI to confirm the flags as WordPress sees them:

wp eval '
$obj = get_post_type_object( "your-cpt" );
if ( ! $obj ) {
    echo "not registered\n";
    exit;
}
echo "public: " . var_export( $obj->public, true ) . "\n";
echo "show_in_rest: " . var_export( $obj->show_in_rest, true ) . "\n";
echo "rest_base: " . $obj->rest_base . "\n";
'

If public or show_in_rest is false, that is your root cause. Fix the registration arguments. Both must be true for the default post search handler to include the CPT.

2. Inspect the search response’s subtype field

Request the search endpoint with the subtype set to your CPT slug and compare against the subtype values the response reports:

curl -s 'https://example.com/wp-json/wp/v2/search?search=quarterly&subtype=your-cpt' | jq '.subtype'

If the response is an empty array or an error, the subtype is not recognized. If it returns results, the registration is fine and the earlier absence was a status or query issue. The subtype field in the response is the observable that distinguishes “not registered as searchable” from “registered but empty.”

3. Inspect the handler list

Only after the first two checks should you look at the handler registry. A quick eval confirms what is registered:

wp eval '
$handlers = apply_filters( "wp_rest_search_handlers", array() );
foreach ( $handlers as $handler ) {
    echo get_class( $handler ) . " => " . $handler->get_type() . "\n";
    print_r( $handler->get_subtypes() );
}
'

If the default post handler is present and its subtype list includes your CPT, the handler list is not your problem. If the subtype list does not include your CPT, return to step 1.

4. When a custom handler is actually warranted

A custom handler is appropriate when you need to search an object type the post handler cannot represent: a custom database table, an external index, a non-post entity. It is not appropriate for a CPT that the post handler already covers.

WP_REST_Search_Handler declares three abstract methods that a subclass must implement:

  • search_items( WP_REST_Request $request ) — returns an associative array with RESULT_IDS (an array of found IDs) and RESULT_TOTAL (the total count).
  • prepare_item( $id, array $fields ) — returns the fields for a given item.
  • prepare_item_links( $id ) — returns links for a given item.

The base class also provides get_type() and get_subtypes(), which return the $type and $subtypes properties set by the subclass. A minimal skeleton:

class My_CPT_Search_Handler extends WP_REST_Search_Handler {
    public function __construct() {
        $this->type = 'my-cpt';
        $this->subtypes = array( 'my-cpt' );
    }

    public function search_items( WP_REST_Request $request ) {
        $query = new WP_Query( array(
            'post_type' => 'my-cpt',
            'post_status' => 'publish',
            's' => $request['search'],
            'paged' => (int) $request['page'],
            'posts_per_page' => (int) $request['per_page'],
        ) );
        return array(
            self::RESULT_IDS => wp_list_pluck( $query->posts, 'ID' ),
            self::RESULT_TOTAL => $query->found_posts,
        );
    }

    public function prepare_item( $id, array $fields ) {
        $post = get_post( $id );
        $data = array();
        if ( in_array( WP_REST_Search_Controller::PROP_ID, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_ID ] = (int) $post->ID;
        }
        if ( in_array( WP_REST_Search_Controller::PROP_TITLE, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_TITLE ] = get_the_title( $post->ID );
        }
        if ( in_array( WP_REST_Search_Controller::PROP_URL, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_URL ] = get_permalink( $post->ID );
        }
        if ( in_array( WP_REST_Search_Controller::PROP_TYPE, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_TYPE ] = $this->type;
        }
        if ( in_array( WP_REST_Search_Controller::PROP_SUBTYPE, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_SUBTYPE ] = $post->post_type;
        }
        return $data;
    }

    public function prepare_item_links( $id ) {
        $post = get_post( $id );
        $links = array();
        $item_route = rest_get_route_for_post( $post );
        if ( ! empty( $item_route ) ) {
            $links['self'] = array(
                'href' => rest_url( $item_route ),
                'embeddable' => true,
            );
        }
        $links['about'] = array(
            'href' => rest_url( 'wp/v2/types/' . $post->post_type ),
        );
        return $links;
    }
}

add_filter( 'wp_rest_search_handlers', function( $handlers ) {
    $handlers[] = new My_CPT_Search_Handler();
    return $handlers;
} );

Register this only when the default post handler genuinely cannot cover the object type. If you register it for a CPT that is already public and REST-exposed, you will get duplicate entries in search results.

Verification

Confirm the subtype appears in a live search response before and after the registration change. A minimal check:

curl -s 'https://example.com/wp-json/wp/v2/search?search=quarterly&subtype=your-cpt' | jq '.subtype'

Before the fix, expect an empty array or an error. After correcting the registration flags, expect the CPT slug to appear in the subtype values. If you added a custom handler, verify that the default post handler’s results are not duplicated.

For a broader diagnostic sequence on empty search results, see What to Fix First When a New WordPress Site Says Nothing Found. The same order applies here: check registration and status before touching the handler registry.

FAQ

Does the search endpoint search all post types by default?

No. The type parameter defaults to post, and the default post search handler’s subtype list is built from public, REST-exposed post types. A CPT that is not public or not exposed to REST will not appear.

Why does my CPT appear at its own REST route but not in search?

The collection route for a CPT is registered by WP_REST_Posts_Controller when the post type has show_in_rest true. The search endpoint uses a separate handler whose subtype list requires both public and show_in_rest to be true. A CPT with show_in_rest true but public false can have a working collection route and still be absent from search.

Can I add my CPT to search with the wp_rest_search_handlers filter?

You can, but you should not need to for a standard CPT. The default post handler already covers public, REST-exposed post types. Adding a handler for a CPT the default handler already covers will produce duplicate results. Use the filter for object types the post handler cannot represent.

What if my CPT entries are all drafts?

The default post search handler queries with post_status => 'publish'. Drafts, pending, and private entries will not appear. This is a status issue, not a registration issue. Publish at least one entry and retest before changing code.

Where is the wp_rest_search_handlers filter applied?

It is applied in create_initial_rest_routes(), which registers the default REST API routes. Each handler instance passed through the filter must extend WP_REST_Search_Handler.

What methods must a custom search handler implement?

WP_REST_Search_Handler declares search_items(), prepare_item(), and prepare_item_links() as abstract. search_items() must return an array with RESULT_IDS and RESULT_TOTAL keys.

Sources

Why Your Block Deprecation Silently Fails: How the Editor Validates save() Output Against block.json Attributes Before It Ever Migrates Old Markup

You changed the wrapper element in save(). You added a deprecated entry with a migrate() that renames the attribute. You opened an old post. The block still shows the invalid-content warning, and your migrate() never runs. You add a console.log inside it. Nothing. You conclude the deprecation API is broken.

It is not broken. It is gated. The editor does not run your migration because your deprecation’s save() did not reproduce the stored markup, and a deprecation whose save() fails validation is skipped entirely — including its migrate(). The migration logic is downstream of a validation check that most people debug in the wrong order.

Symptom

Old block instances show the invalid-content warning after a save() or attribute change. The deprecated array is populated. The migrate() function is correct in isolation. It does not execute.

If you instrument the deprecation object, you will find that isEligible may run, but migrate does not. That asymmetry is the tell: the entry was considered, its save() was evaluated, and the result did not validate. The entry was discarded before the migration stage.

Trace

The selection loop is documented in the Block Deprecation reference. The relevant sequence, quoted from the WordPress Block Editor handbook:

If the current save method does not produce a valid block the first deprecation in the deprecations array is passed the original saved content. If its save method produces valid content this deprecation is used to parse the block attributes. If it has a migrate method it will also be run using the attributes parsed by the deprecation. If the first deprecation’s save method does not produce a valid block the subsequent deprecations in the array are tried until one producing a valid block is encountered.

And the gate that produces the silent skip:

It is important to note that if a deprecation’s save method does not produce a valid block then it is skipped completely, including its migrate method, even if isEligible would return true for the given attributes.

Two things follow from that. First, the deprecation array is not a migration chain. The documentation is explicit: “Deprecations do not operate as a chain of updates in the way other software data updates, like database migrations, do.” Each entry is evaluated against the original saved content, not against the output of the previous entry. Second, the entry that validates is the only one whose attributes are passed forward. The attributes and inner blocks from the first deprecation that produces valid content are passed back to the current save() to generate new content. Earlier entries are not fed forward.

So the loop is a search, not a pipeline. It looks like this:

stored markup
  │
  ├─ current save() validates? ──> use current block, stop
  │
  └─ invalid
       │
       ├─ deprecation[0].save() validates? ──> parse attrs with deprecation[0].attributes
       │                                        run deprecation[0].migrate() if present
       │                                        pass attrs + innerBlocks to current save()
       │                                        stop
       │
       ├─ deprecation[1].save() validates? ──> same, with deprecation[1]
       │
       └─ ...

If no entry validates, the block remains invalid. The editor does not fall through to the current save() with guessed attributes. It does not run any migrate(). It does not warn you that your save() was the problem.

Root cause

The validation check is not comparing your deprecation’s save() output to the current block’s save() output. It is comparing the serialized result of the deprecation’s save() — using the deprecation’s own attribute definitions — to the original stored markup. The @wordpress/blocks reference states the validity condition plainly: “A block is considered valid if, when serialized with assumed attributes, the content matches the original value.”

That means three things must line up for a deprecation to validate:

  1. The deprecation’s attributes definition must parse the stored markup into the same attribute values the original save() used.
  2. The deprecation’s save() must serialize those attributes back into markup byte-identical to what is stored.
  3. The deprecation’s supports must produce the same wrapper class and attribute output the original block produced.

The documentation is direct about the inheritance trap: “attributes, supports, and save are not automatically inherited from the current version, since they can impact parsing and serialization of a block, so they must be defined on the deprecated object in order to be processed during a migration.” A deprecation that omits supports and relies on the current block’s supports will serialize a different wrapper. The comparison fails. The entry is skipped.

The same applies to sourced attributes. The attributes reference notes: “The block is responsible for using the save function to ensure that all attributes with a source field are saved according to the attributes definition. This is not automatic.” If your deprecation’s attributes declares a source: 'html' selector that no longer matches the stored markup — because the stored markup used a different tag, class, or nesting — the parsed attribute is empty or wrong, the serialized output diverges, and validation fails.

There is a second, quieter failure mode. If a deprecation’s save() imports a helper from the current codebase, and that helper changes, the deprecation’s historical output changes with it. The documentation warns: “if a deprecation’s save method imports additional functions from other files, changes to those files may accidentally change the behavior of the deprecation.” The deprecation was correct when you wrote it. It stopped being correct when someone edited a shared utility. Nothing in the deprecation file changed, so nothing in review flagged it.

Fix

Treat each deprecation entry as a frozen snapshot of a released block definition, not as a patch against the current one.

Reproduce the historical save() exactly

Copy the save() implementation from the released tag that produced the stored markup. Do not rewrite it from memory. Do not simplify it. The comparison is against the original serialized value, so the deprecation’s save() must produce that value, not an equivalent one.

// deprecated/v1/save.js
// Frozen copy of the save() from the release that produced the stored markup.
// Do not refactor. Do not import shared helpers that can change.
export default function save( { attributes } ) {
    return (
        <div className="notice">
            <p>{ attributes.text }</p>
        </div>
    );
}

Define attributes and supports on the deprecation

Do not rely on inheritance. The deprecation object must carry its own attributes and supports definitions, matching the release that produced the stored markup.

// deprecated/v1/index.js
import save from './save';

const attributes = {
    text: {
        type: 'string',
        source: 'html',
        selector: 'p',
    },
};

const supports = {
    className: false,
};

export default {
    attributes,
    supports,
    save,
    migrate( oldAttributes ) {
        return {
            content: oldAttributes.text,
            tone: 'info',
        };
    },
};

If the stored markup was produced with supports.className enabled, the deprecation must declare it. If the stored markup had no wrapper class, the deprecation must declare className: false. The serialized output is compared literally.

Order entries newest first

The documentation recommends reverse chronological order: “The deprecations in the array should be in reverse chronological order. This allows the block editor to attempt to apply the most recent and likely deprecations first, avoiding unnecessary and expensive processing.” The newest historical format is usually the most common in installed content, so it should be attempted first.

import v3 from './deprecated/v3';
import v2 from './deprecated/v2';
import v1 from './deprecated/v1';

export const deprecated = [ v3, v2, v1 ];

Add new migrations to every deprecation that can still match

Because only the first validating entry’s attributes are passed forward, a new migration must be present in every deprecation that can still match installed content. The documentation states this directly: “if you have several deprecations for a block and want to perform a new migration, like moving content to InnerBlocks, you may need to update the migrate methods in multiple deprecations in order for the required changes to be applied to all previous versions of the block.”

If v1 and v2 both validate against content still in the database, and you add a new attribute rename, both v1 and v2 need the rename in their migrate(). Adding it only to v2 leaves v1 content migrated to the v2 shape, not the current shape.

Use isEligible only for valid-but-stale content

The deprecation is tried when the parsed block is invalid, or when isEligible returns true. That second path exists for content that is technically valid but still needs normalization — a renamed enum value, a class that should be removed, an attribute that should be dropped.

isEligible( attributes ) {
    return attributes.alignment === 'centre';
},
migrate( attributes ) {
    return { ...attributes, alignment: 'center' };
},

Keep the predicate narrow. A broad isEligible that returns true for current content will cause the editor to re-run the migration on every load, which is both wasteful and a source of dirty-post state.

Verification

You cannot verify a deprecation by reading it. You verify it by feeding it the exact serialized markup it is supposed to recognize and confirming that the editor accepts it.

Keep fixtures per released format

The documentation recommends this: “It is also recommended to keep fixtures which contain the different versions of the block content to allow you to easily test that new deprecations and migrations are working across all previous versions of the block.” One fixture per public serialized format, captured from a real post created with the released build.

fixtures/
  v1.html
  v2.html
  v3.html

For each fixture, confirm four things:

  1. The editor loads it without an invalid-content warning.
  2. Attributes and inner blocks parse without losing user content.
  3. Saving converts it to the current serialized format.
  4. Reopening the migrated post remains valid.

Inspect the parsed attributes before the migration runs

In the editor console, you can inspect what the parser extracted from a stored block. The @wordpress/blocks package exposes getBlockAttributes, which “returns the block attributes of a registered block node given its type,” and getSaveContent, which “returns the static markup to be saved, given a block type containing a save render implementation and attributes.” You can use these to compare the deprecation’s parsed attributes and serialized output against the stored markup without guessing.

// In the editor console, with the block selected.
const { getBlockAttributes, getSaveContent, getBlockType } = wp.blocks;
const blockType = getBlockType( 'my-plugin/notice' );
const stored = wp.data.select( 'core/block-editor' )
    .getBlock( clientId ).originalContent;

const parsed = getBlockAttributes( blockType, stored );
const reserialized = getSaveContent( blockType, parsed );

console.log( { stored, parsed, reserialized, match: stored === reserialized } );

If match is false, the current block does not validate the stored content, and the deprecation loop will run. If you then swap in the deprecation’s attributes and save and match is still false, that deprecation will be skipped. That is the check to run before you touch migrate().

Check the database for stale markup

Block markup lives in wp_posts.post_content. You can count how many posts still contain the old serialized form before and after a migration pass.

SELECT ID, post_title
FROM wp_posts
WHERE post_status = 'publish'
  AND post_content LIKE '%<!-- wp:my-plugin/notice %'
  AND post_content LIKE '%<p>%'
ORDER BY post_modified DESC
LIMIT 20;

Adjust the LIKE patterns to the distinguishing markup of the old format. This tells you which posts still carry the old shape and whether the editor has rewritten them. It does not tell you whether the deprecation validated — only the editor can tell you that — but it tells you the scope of the problem.

WP-CLI for a content-level check

You can search post content for the old delimiter and attribute shape without opening the editor.

wp post list --post_type=post --post_status=publish \
  --field=ID \
  --meta_key=_does_not_exist \
  --format=ids | xargs -n1 -I{} wp post get {} --field=content \
  | grep -c 'wp:my-plugin/notice'

That count is a rough inventory. It is not a validation result. The validation result comes from the editor, because the editor is the only place where the block type’s save() and the deprecation’s save() are both registered and compared.

What to check before the next save() change

Before merging any change to save() or to a sourced attribute, answer these questions:

  1. Does the new save() regenerate markup identical to the latest released version? If not, is the markup change necessary rather than cosmetic?
  2. Does every historical deprecation still reproduce its own released markup, using its own attributes and supports?
  3. Does every released fixture migrate directly to the current format, without relying on another deprecation having run first?
  4. Does every deprecation that can still match installed content carry the latest migrate() changes?
  5. Does any deprecation import a helper that can change independently of the deprecation file?

If the answer to any of those is no, the deprecation will be skipped at validation time, and the migrate() you wrote will not run. The fix is not in migrate(). It is in the save() and the attribute definitions that gate it.

FAQ

Why does my migrate() never run even though isEligible returns true?

Because the deprecation’s save() did not produce valid content. The documentation states that a deprecation whose save() does not produce a valid block is skipped completely, including its migrate(), even if isEligible would return true. The isEligible check is not a bypass around validation; it is an additional trigger for entries that would otherwise not be tried because the block is already valid.

Can I rely on the current block’s attributes and supports in a deprecation?

No. The documentation is explicit that attributes, supports, and save are not automatically inherited from the current version, because they affect parsing and serialization. Each deprecation must define them.

Do deprecations run in sequence, each building on the previous one?

No. The documentation states that deprecations do not operate as a chain of updates the way database migrations do. Each entry is tried against the original saved content until one produces valid content. Only that entry’s attributes and inner blocks are passed forward to the current save().

What order should the deprecated array be in?

Reverse chronological order, newest first. The documentation recommends this so the editor attempts the most recent and likely deprecations first, avoiding unnecessary processing.

How do I know which deprecation is failing?

You do not get a direct signal from the editor. The practical approach is to reproduce the stored markup against each deprecation’s attributes and save() in isolation, using getBlockAttributes and getSaveContent from @wordpress/blocks, and confirm that the serialized output matches the stored content. The first entry that matches is the one the editor will use. If none match, the block stays invalid.

Is isValidBlockContent still the right function to check?

The @wordpress/blocks reference marks isValidBlockContent as deprecated and says to use validateBlock instead to avoid data loss. The validity condition it describes is the same: a block is valid if, when serialized with assumed attributes, the content matches the original value.

Related

If you are still at the stage where a fresh install shows nothing on the front end, the failure is earlier in the stack than block validation. What to Fix First When a New WordPress Site Says Nothing Found covers the front-end rendering path before you start debugging the editor.

Sources

The Caution Triangle as a Contract: Reading the ‘Not Friendly Do Not Touch’ Warning Sign

Editorial note: This publication and Quirky Cotton Collective share an owner, so treat what follows as an owner-authorized reading of the artwork rather than an independent review.

The graphic is a warning sign. Not a joke about a warning sign — the sign itself, borrowed whole. Across the chest, in the block lettering you would find on a wet-floor placard or a high-voltage cabinet, the words NOT FRIENDLY sit above DO NOT TOUCH. Between them, or beside them depending on the print, a yellow caution triangle holds a hand with a diagonal bar through it. The palette is the standard hazard vocabulary: yellow field, black mark, heavy sans-serif capitals. Nothing about the composition asks to be read as a slogan. It asks to be read as signage.

That distinction is the whole argument. A slogan is a claim about the wearer — I am this. A sign is a claim about the environment — this surface behaves this way. The not friendly do not touch shirt works because it chooses the second register. It does not say the wearer is an introvert, or tired, or busy. It says the wearer is a surface with known properties, and the properties are posted in advance.

What the sign actually promises

Warning signs are interesting objects because they are contracts written in advance of the event they describe. The yellow triangle does not prevent the fall. It transfers information: this condition exists, and you are now responsible for how you respond to it. The crossed-out hand is the enforcement clause. It does not say touching is impossible. It says touching is outside the terms.

Read that way, the shirt is doing something more precise than the usual introvert merchandise. The common version of this genre says I am shy or I need a minute — statements about a person’s interior state, which the viewer is invited to find charming or relatable. This design skips the interior entirely. It posts a boundary as a fact about the exterior. The wearer does not have to explain, apologize, or perform a mood. The sign has already done the disclosure, in the least ambiguous typography available.

I find that more honest than the alternative. A shirt that says sorry, I’m awkward asks the room for accommodation. A shirt that says DO NOT TOUCH states a rule. Rules are easier to follow than moods, because they do not require the reader to interpret anything.

Why the caution triangle carries the design

The triangle is doing the heavy lifting, and it is worth being specific about why. Hazard signage is one of the few visual languages that has been standardized across contexts — industrial floors, roadways, equipment panels, chemical storage. You have seen this triangle your entire life, and you have never once had to decode it. The shape means attention. The yellow means caution, not prohibition. The black mark inside means the specific hazard.

When that vocabulary is transplanted onto a t-shirt, it arrives pre-loaded. The viewer does not need to read the words to get the message; the triangle has already set the tone before the lettering resolves. That is efficient design. It also explains why the crossed-out hand reads as a prohibition rather than a rejection — the hand is a hazard symbol, not a person. The sign is not saying you are unwelcome. It is saying contact is the hazard.

That is a meaningful difference, and I think it is why the design avoids the misanthropy that this kind of shirt can easily slide into. A crossed-out hand in a caution triangle is clinical. It is the same mark you would find on a hot surface or a moving part. It does not editorialize about the person approaching. It describes the surface.

The difference between a slogan and a usable promise

Most boundary humor fails because it overpromises. A shirt that says I hate everyone is a slogan: it makes a claim the wearer cannot actually honor, because the wearer will inevitably need to interact with someone, and the shirt will then be revealed as a bit. The bit is fine. The bit is not a boundary.

The warning-sign framing avoids that failure mode because it is scoped. NOT FRIENDLY is not the same claim as hostile. It is a statement about default mode, not about capacity. DO NOT TOUCH is not the same claim as do not speak to me. It is a statement about one specific channel. The sign is narrow on purpose. Narrow claims are the ones that survive contact with reality.

This is the part I keep coming back to when I look at the design. It is a piece of clothing that behaves like documentation. It states the condition, states the constraint, and stops. No explanation, no apology, no invitation to negotiate. If you have ever written a README that says this function does not validate input and then declined to add a paragraph about why, you recognize the instinct. The sign is the docstring. The reader can take it or leave it.

What the shirt is not

It is not a security control. A printed warning does not enforce anything, and I would not want to imply that wearing a caution triangle changes how anyone behaves. It is a disclosure, not a mechanism. The distinction matters because the design’s appeal is precisely that it does not pretend to be more than a disclosure. It is a sign. Signs inform. They do not compel.

It is also not a personality test. The design does not claim the wearer is introverted, and the retail title’s use of that word is a search-facing description, not a printed element. What is printed is the warning language and the symbol. Everything else is context the viewer brings.

And it is not subtle. That is the point. The typography is heavy, the triangle is bright, the message is short. If you are going to post a boundary on your torso, the least you can do is make it legible from across a room.

The case for the sign over the confession

There is a version of this shirt that would have been a confession: I’m not friendly, sorry, in a soft script, with a little shrug. That shirt would have been fine. It would also have been a request. The version that exists is a notice. It does not ask the viewer to be understanding. It tells the viewer what the surface is.

I prefer the notice. Not because I think boundaries should be cold, but because I think they should be clear. Clarity is a kindness. A sign that says exactly what it means, in the visual language everyone already knows, is doing the viewer a favor. It removes the guesswork. It removes the need for the wearer to explain. It removes the awkward middle where both parties are trying to figure out what the other one wants.

The caution triangle is not decoration. It is the part of the design that turns a sentence into a system. And a system, even a small one printed on cotton, is more useful than a mood.

wp_enqueue_block_style(): What It Actually Does With the path Argument, and Why the Editor Symptom Is a Hypothesis

You registered a block stylesheet with wp_enqueue_block_style(). The front end looks correct. The editor does not. The obvious next move is to search for a missing argument, find path in the function signature, add it, and declare victory. That move is not supported by the function source. The editor symptom and the path argument are two separate questions, and the function body answers them in a specific order.

This is a control-flow walkthrough. The goal is to separate the branch that decides when the stylesheet loads from the data that decides what core can do with it. The proposed framing — that the editor ignores the stylesheet without path — is not established by the retrieved documentation or source. Treat it as a hypothesis to test, not a mechanism to assume.

Symptom

A block stylesheet registered through wp_enqueue_block_style() renders on the front end but appears absent in the block editor. The two candidate causes are structurally different:

  1. Branch selection. The function attaches its callback to different hooks depending on whether block assets are loaded on demand. If the on-demand branch is active, the callback is attached to render_block and never reaches the editor registration in the non-on-demand branch.
  2. Missing path data. The path argument feeds wp_style_add_data( $handle, 'path', $path ) and the RTL file swap. It is documented as the absolute path to the stylesheet “so that it can potentially be inlined.” It is not documented as a gate on editor loading.

Before changing anything, determine which branch is active on the request that renders the editor. The rest of this article walks the function body in the order it executes.

Trace: the function body in order

The reference page documents wp_enqueue_block_style( string $block_name, $args ) as enqueuing a stylesheet for a specific block. The description states: “If the theme has opted-in to load block styles on demand, then the stylesheet will be enqueued on-render, otherwise when the block inits.”

The source begins with argument defaults:

$args = wp_parse_args( $args, array(
    'handle' => '',
    'src'    => '',
    'deps'   => array(),
    'ver'    => false,
    'media'  => 'all',
) );

Note what is not in the defaults: path. It is optional. The shared callback then registers the style, conditionally adds path and RTL data, and enqueues:

$callback = static function ( $content ) use ( $args ) {
    if ( ! empty( $args['src'] ) ) {
        wp_register_style( $args['handle'], $args['src'], $args['deps'], $args['ver'], $args['media'] );
    }

    // Add `path` data if provided.
    if ( isset( $args['path'] ) ) {
        wp_style_add_data( $args['handle'], 'path', $args['path'] );

        // Get the RTL file path.
        $rtl_file_path = str_replace( '.css', '-rtl.css', $args['path'] );

        // Add RTL stylesheet.
        if ( file_exists( $rtl_file_path ) ) {
            wp_style_add_data( $args['handle'], 'rtl', 'replace' );
            if ( is_rtl() ) {
                wp_style_add_data( $args['handle'], 'path', $rtl_file_path );
            }
        }
    }

    wp_enqueue_style( $args['handle'] );
    return $content;
};

Two facts follow directly from this excerpt. First, path is only consumed when set; when absent, no path data is attached and no RTL swap occurs. Second, the documented purpose of path is inlining, and the source shows it also driving the RTL file existence check and the is_rtl() swap. That makes path a data-completeness argument, not a loading switch.

The branch that decides when the callback runs

After the callback is defined, the function selects a hook based on whether wp_enqueue_scripts has already fired:

$hook = did_action( 'wp_enqueue_scripts' ) ? 'wp_footer' : 'wp_enqueue_scripts';

Then it checks the on-demand condition:

if ( wp_should_load_block_assets_on_demand() ) {
    $callback_separate = static function ( $content, $block ) use ( $block_name, $callback ) {
        if ( ! empty( $block['blockName'] ) && $block_name === $block['blockName'] ) {
            return $callback( $content );
        }
        return $content;
    };

    add_filter( 'render_block', $callback_separate, 10, 2 );
    return;
}

add_filter( $hook, $callback );

// Enqueue assets in the editor.
add_action( 'enqueue_block_assets', $callback );

The reference page documents wp_should_load_block_assets_on_demand() as checking “whether block styles should be loaded only on-render.” When that check returns true, the function attaches the wrapper to render_block and returns. The wp_enqueue_scripts/wp_footer registration and the enqueue_block_assets registration are never reached on that call.

This is the practical debugging lever. The two branches are mutually exclusive at call time. Any advice that assumes the editor registration is active is conditional on wp_should_load_block_assets_on_demand() returning false.

The render_block filter is documented in the Block Editor handbook as filtering “the front-end content of any block” and as having “no impact on the behavior of blocks in the Editor.” That is consistent with the source: in the on-demand branch, the callback is attached to a front-end render filter, and the editor registration is skipped.

What the source does and does not establish about the editor

The retrieved source shows an enqueue_block_assets registration in the non-on-demand branch, with the comment “Enqueue assets in the editor.” It does not show any condition under which a missing path argument prevents that registration from running. The documentation describes path as feeding inlining data. Nothing retrieved states that path is required for the stylesheet to load in the editor at all.

So the claim “the editor ignores the stylesheet without the path argument” is not supported by the sources retrieved for this article. The honest framing is: the editor symptom has at least two candidate causes, and the function body gives you a way to distinguish them. Do not skip the branch check because the path argument looks like the missing piece.

Root cause candidates, ordered by cost to check

1. The on-demand branch is active and the editor registration is unreachable

If wp_should_load_block_assets_on_demand() returns true, the function returns after attaching to render_block. The editor registration in the non-on-demand branch does not execute. A user-contributed note on the reference page states that to load block CSS only when the block is present on the page, you should filter should_load_separate_core_block_assets to return true. The same note says this “should happen automatically if you are using FSE,” but that sentence is hedged and version-unspecified; treat it as a lead, not a rule.

Check which branch is active before touching path:

add_action( 'init', function () {
    error_log( 'on-demand: ' . ( wp_should_load_block_assets_on_demand() ? 'true' : 'false' ) );
} );

Or, without editing code, use WP-CLI to inspect the filter state on a request that renders the editor. The exact command depends on your setup; the point is to observe the boolean, not to guess it.

2. The handle collides with a handle core already uses

A user-contributed note warns: “If your block is called ‘namespace/name’, do not set handle argument to ‘namespace-name-style’, because that style is already used by the WordPress to enqueue block styles and style will not be enqueued.” This produces a silent non-enqueue with no error. It is a cheap first check.

Inspect registered styles for the handle before and after your registration:

wp eval 'global $wp_styles; var_dump( isset( $wp_styles->registered["namespace-name-style"] ) );'

If the handle is already registered by core, rename yours. The collision is not a bug in your code; it is a namespace you do not own.

3. The callback is hooked to the same hook the function uses internally

A user-contributed note states: “This function can be called directly, without hooking it to specific action, because it hooks enqueuing internally to enqueue_block_assets. If it is hooked to the same hook, it will not work.” If you wrapped the call in add_action( 'enqueue_block_assets', ... ), the internal registration and your wrapper compete on the same hook. Call the function directly, or hook it to after_setup_theme as the contributed example does.

4. The block name never appears in rendered block content

In the on-demand branch, the wrapper compares $block['blockName'] to the registered $block_name. A stylesheet registered for a block name that never appears in rendered block content will not be enqueued on the front end at all. This is a plausible source of “my styles are missing” reports that has nothing to do with path. Confirm the block name matches the registered name exactly, including namespace.

5. Path data is absent, so core cannot inline or resolve the RTL file

If the branch is correct, the handle is unique, and the block name matches, then path is worth setting — not because it gates editor loading, but because it is the data core uses for inlining and for the RTL swap. Without it, wp_style_add_data( $handle, 'path', $path ) is never called, and the -rtl.css existence check never runs.

wp_enqueue_block_style( 'namespace/name', array(
    'handle' => 'namespace-name-block-style',
    'src'    => get_theme_file_uri( 'assets/css/blocks/name.css' ),
    'path'   => get_theme_file_path( 'assets/css/blocks/name.css' ),
) );

The path value is an absolute filesystem path, not a URL. The src value is the URL. Mixing them up is a common source of confusion; the source uses path only for file_exists() and for the style data key.

Fix

Order the fix by the checks above. Do not start with path.

  1. Confirm which branch is active. If on-demand is true, the editor registration is not reached on that call. Decide whether that is the behavior you want before changing anything else.
  2. Confirm the handle is not already registered by core. Rename if it is.
  3. Confirm the call is not wrapped in enqueue_block_assets. Call it directly or from after_setup_theme.
  4. Confirm the registered block name matches the block name in rendered content.
  5. Set path to the absolute filesystem path so core can attach path data and check for an RTL file.

If the editor still does not show the styles after all five, the problem is likely outside this function. The render_block filter is documented as having no impact on editor behavior, so a stylesheet that depends on that filter for its front-end presence will not appear in the editor through that path. That is a structural property of the branch, not a missing argument.

Verification

Run these checks in order. Each one rules out a candidate before you move to the next.

Branch check. Log wp_should_load_block_assets_on_demand() on the editor request. If it returns true, the editor registration in the non-on-demand branch is not active on that call.

Handle check. Inspect $wp_styles->registered for your handle before your registration runs. If it exists, core or another plugin owns it.

Path data check. After your registration runs, confirm the style has path data attached:

wp eval '
  global $wp_styles;
  $h = "namespace-name-block-style";
  if ( isset( $wp_styles->registered[ $h ] ) ) {
    var_dump( $wp_styles->get_data( $h, "path" ) );
  }
'

If this returns false or null, path was not set or was not consumed. If it returns a string, path data is attached.

Editor reachability check. Confirm the enqueue_block_assets registration is reachable on the request that renders the editor. If the on-demand branch returned early, it is not.

Block name check. Confirm the block name passed to wp_enqueue_block_style() matches the blockName in rendered content. In the on-demand branch, a mismatch means the callback never fires for that block.

FAQ

Does path control whether the stylesheet loads in the editor?

No retrieved source states that. The documentation describes path as the absolute path to the stylesheet “so that it can potentially be inlined.” The source shows it feeding wp_style_add_data( $handle, 'path', $path ) and the RTL file swap. It is a data argument, not a loading switch.

Why does the front end work but the editor not?

The most likely structural reason is that the on-demand branch is active. In that branch, the callback is attached to render_block, which the Block Editor handbook documents as filtering front-end content and as having no impact on editor behavior. The editor registration in the non-on-demand branch is not reached.

Can I call wp_enqueue_block_style() without hooking it?

A user-contributed note states that the function can be called directly because it hooks enqueuing internally to enqueue_block_assets, and that hooking it to the same hook will not work. The contributed example uses after_setup_theme.

What does wp_should_load_block_assets_on_demand() check?

The reference page documents it as checking “whether block styles should be loaded only on-render.” The exact conditions are not stated on that page. Observe the return value on your own install rather than assuming.

Why is my style not enqueued at all?

Check the handle first. A user-contributed note warns that a handle matching the namespace-name-style pattern core already uses will not be enqueued. Then check the block name: in the on-demand branch, a mismatch between the registered name and $block['blockName'] means the callback never fires.

Sources

Two sources referenced during research failed retrieval and are not cited: the wp_register_block_style_handle() reference page and Trac ticket 54337. Claims that would have depended on them are omitted.

Why Posts Sit at ‘Missed Schedule’: The _transient_doing_cron Lock, WP_CRON_LOCK_TIMEOUT, and the spawn_cron() Gates

A post with a past timestamp and a status of future is not a post problem. The editor labels it Missed Schedule because WordPress expected the post to be published by a scheduled event, and that event has not run. The post record is fine. The spawn path is not.

This is a trace of that spawn path: how wp_cron() registers the runner, how spawn_cron() uses the doing_cron transient as a lock, what WP_CRON_LOCK_TIMEOUT gates, and why the distinction between a held lock and a stale lock determines your next move.

Symptom

You open a post that was scheduled for 09:00. It is now 09:40. The editor shows Missed Schedule. The wp_posts row still has post_status = 'future' and a post_date_gmt in the past. Nothing in the post table explains why. The explanation lives in the cron option and the transient that guards it.

Two things must both be true for the post to publish:

  1. The publish event must exist in the cron array with a timestamp that has passed.
  2. Something must actually invoke wp-cron.php so that event is drained.

Missed Schedule means one of those failed. The label does not tell you which.

Trace: how the spawn path is wired

WordPress does not run cron on a system timer by default. It runs it when a page request finishes. The registration happens in wp_cron(), which the documentation describes as registering _wp_cron() to run on the shutdown action. The same documentation notes that as of 6.9.0 the callback moved from wp_loaded to shutdown unless ALTERNATE_WP_CRON is enabled, and that the function now always returns void.

The source shown in the reference is explicit about the branch:

function wp_cron(): void {
    if ( defined( 'ALTERNATE_WP_CRON' ) && ALTERNATE_WP_CRON ) {
        if ( did_action( 'wp_loaded' ) ) {
            _wp_cron();
        } else {
            add_action( 'wp_loaded', '_wp_cron', 20 );
        }
    } elseif ( doing_action( 'shutdown' ) ) {
        _wp_cron();
    } else {
        add_action( 'shutdown', '_wp_cron' );
    }
}

So on a normal install, _wp_cron() runs at shutdown. Under ALTERNATE_WP_CRON, it runs at wp_loaded. That difference matters when you are reading a slow page and a missed schedule at the same time.

_wp_cron() is the function that decides whether to run due events inline or to spawn a loopback request. The spawning itself is spawn_cron(), documented as sending a request to run cron through an HTTP request that does not halt page loading.

Trace: the lock arithmetic in spawn_cron()

Before spawn_cron() fires anything, it reads the doing_cron transient and treats it as a float. The reference source shows the guard:

$lock = (float) get_transient( 'doing_cron' );
if ( $lock > $gmt_time + 10 * MINUTE_IN_SECONDS ) {
    $lock = 0;
}

// Don't run if another process is currently running it or more than once every 60 sec.
if ( $lock + WP_CRON_LOCK_TIMEOUT > $gmt_time ) {
    return false;
}

Three things are happening here.

First, the transient is read as a float. It is written as a microsecond-precision string, so the cast is not cosmetic.

Second, a lock more than ten minutes in the future is treated as zero. That is a sanity check against a clock skew or a corrupted value, not a timeout.

Third, the real gate: if $lock + WP_CRON_LOCK_TIMEOUT is still ahead of the current time, spawn_cron() returns false and does nothing. The comment in core describes this as preventing a second run while another process is running it, or more than once every sixty seconds.

The constant WP_CRON_LOCK_TIMEOUT is referenced here but its value is not stated in the retrieved reference. Do not assume a number. Read it from your install:

wp eval 'var_dump( WP_CRON_LOCK_TIMEOUT );'

If that returns nothing, the constant is not defined in your bootstrap and you are looking at a different problem than the one this article covers.

Trace: the second gate, the ready queue

Even with a clear lock, spawn_cron() will not spawn if there is nothing ready to run. The reference source shows two more returns:

$crons = wp_get_ready_cron_jobs();
if ( empty( $crons ) ) {
    return false;
}

$keys = array_keys( $crons );
if ( isset( $keys[0] ) && $keys[0] > $gmt_time ) {
    return false;
}

This is the gate that produces the most confusing version of the symptom. If every event in the cron array is dated in the future, wp_get_ready_cron_jobs() returns an empty set and spawn_cron() exits without a loopback. Nothing is broken. There is simply nothing due.

That is why a missed schedule investigation should always start by reading the cron option, not by clearing transients.

Trace: the loopback request itself

Once the lock is clear and the ready queue is non-empty, spawn_cron() writes the lock and builds the request. The reference source shows the write:

// Set the cron lock with the current unix timestamp, when the cron is being spawned.
$doing_wp_cron = sprintf( '%.22F', $gmt_time );
set_transient( 'doing_cron', $doing_wp_cron );

Then it builds the URL and the request arguments. The defaults are worth reading closely:

'args' => array(
    'timeout'   => 0.01,
    'blocking'  => false,
    'sslverify' => apply_filters( 'https_local_ssl_verify', false, $cron_url ),
),

A 0.01 second timeout, non-blocking, SSL verification off for the local request. The cron_request filter lets you change all of it.

The wp_cron() documentation adds a caveat that matters for diagnosis: wp_remote_post() does not always respect the timeout and blocking parameters, and a timeout of 0.01 may end up taking one second. The same documentation explains that this is why the callback moved to shutdown: running it at wp_loaded increased Time To First Byte because the HTML could not be sent while waiting for the cron request to initiate.

So the loopback is a hint, not a guarantee. It may complete in milliseconds. It may take a second. It may be dropped by a host that blocks loopback requests. None of that is visible from the editor.

Root cause: held lock versus stale lock

From the post editor, a held lock and a stale lock look identical. Both produce Missed Schedule. They require opposite responses.

A held lock means another process is mid-run. The transient was written by a spawn_cron() call that has not yet been followed by a completed wp-cron.php run. The correct action is to wait, then re-check. If the lock clears and the event drains, nothing was wrong except timing.

A stale lock means the transient outlived its process. The PHP worker that wrote it died, or the loopback never reached wp-cron.php, or wp-cron.php started and was killed before it could clear the lock. The transient is now a tombstone. Until it ages out past WP_CRON_LOCK_TIMEOUT, spawn_cron() will keep returning false and no new loopback will fire.

You cannot tell which one you have by looking at the post. You have to read the transient and compare it to the current time.

Diagnostic sequence

Run these in order. Stop when you find the answer.

1. Read the lock

wp transient get doing_cron

If it returns a float, compare it to time(). If the value is in the past by more than WP_CRON_LOCK_TIMEOUT, the lock is not the blocker. If it is in the future, note by how much.

You can also read it directly:

wp db query "SELECT option_name, option_value, FROM_UNIXTIME(option_value) FROM wp_options WHERE option_name IN ('_transient_doing_cron','_transient_timeout_doing_cron');"

The timeout row tells you when the transient will expire on its own. That is the window during which spawn_cron() will keep bailing.

2. Confirm the event exists and is overdue

wp cron event list --fields=hook,next_run_relative,next_run_gmt,recurrence

Find the publish_future_post entry for your post. If it is not there, the problem is upstream of the lock: the event was never scheduled, or it was scheduled with mismatched arguments and exists under a different key.

The wp_schedule_event() documentation warns about exactly this. Arguments are used to uniquely identify the scheduled event and must match those used when the event was originally scheduled. If they do not match exactly, WordPress treats the event as different, which can lead to duplicate events and growth of the cron option.

Check for duplicates before you assume the lock is the cause:

wp cron event list --hook=publish_future_post --fields=hook,next_run_gmt,args

3. Check the ready queue

wp eval 'print_r( wp_get_ready_cron_jobs() );'

If this is empty while wp cron event list shows overdue events, you have a mismatch between what is stored and what is considered ready. That is a different failure than the lock.

4. Test the loopback

curl -sS -o /dev/null -w '%{http_code} %{time_total}\n' "https://example.com/wp-cron.php?doing_wp_cron"

Replace the host. A 200 with a short time means the endpoint is reachable. A 403, 401, or a timeout means the loopback is blocked and spawn_cron() will keep writing locks that never get cleared.

5. Check the constants

wp eval 'var_dump( defined("DISABLE_WP_CRON") ? DISABLE_WP_CRON : null, defined("ALTERNATE_WP_CRON") ? ALTERNATE_WP_CRON : null );'

If DISABLE_WP_CRON is true, the spawn path is intentionally off and something else must be invoking wp-cron.php. If ALTERNATE_WP_CRON is true, the callback runs at wp_loaded, not shutdown, and the TTFB symptoms differ.

6. Read the cron option directly

wp option get cron --format=json | head -c 2000

This is the raw array. If it is enormous, you have duplicate events and the ready-queue gate is harder to reason about. If it is empty, nothing is scheduled at all.

Fix

The fix depends on which of the two lock states you found.

If the lock is held and recent: wait. Re-check the transient after WP_CRON_LOCK_TIMEOUT has passed. If the event drains, the system worked. If it does not, the lock is stale and you have moved to the next case.

If the lock is stale: clear it and let the next page request spawn a fresh loopback.

wp transient delete doing_cron

Then trigger a request to the site and confirm the event drains:

curl -sS -o /dev/null "https://example.com/"
wp cron event list --hook=publish_future_post --fields=hook,next_run_gmt

If the event is gone and the post is published, the stale lock was the cause. If the event is still there, the loopback is not reaching wp-cron.php and you need to fix that before clearing transients again.

If the ready queue is empty while events are overdue: the lock is not your problem. Inspect the cron option for argument mismatches and duplicates, and reconcile the scheduled events against the hooks your code actually registers.

If the loopback is blocked: either fix the host configuration so wp-cron.php is reachable, or define DISABLE_WP_CRON and drive wp-cron.php from a real system cron. The wp_schedule_event() documentation notes that the default behavior of waiting for a visitor may not suit resource-intensive hooks, and suggests disabling internal cron and fetching the URL from the host control panel or executing the file directly with PHP.

Verification

After the fix, confirm three things:

  1. The transient is gone or has been rewritten with a current timestamp.
  2. The overdue event is no longer in wp cron event list.
  3. The post status is publish, not future.
wp transient get doing_cron
wp cron event list --hook=publish_future_post
wp post get 123 --field=post_status

If all three are clean, the spawn path completed. If the transient reappears with a fresh timestamp but the event does not drain, the loopback is firing and wp-cron.php is not completing. That is a different trace, and it starts with the server error log, not the database.

What this does not cover

The retrieved reference for spawn_cron() and wp_cron() does not include the body of wp-cron.php itself, nor the _get_cron_lock() helper. Claims about how wp-cron.php iterates the queue and clears the lock are outside the evidence used here. If your investigation reaches the point where the loopback fires and the lock is rewritten but events still do not run, you are past the scope of this trace and into the internals of the cron runner.

The pre_schedule_event filter is worth knowing about because it can short-circuit scheduling entirely. The hook reference states that returning a non-null value will short-circuit adding the event to the cron array, causing the function to return the filtered value instead. If a plugin on your install returns a value from that filter for publish_future_post, the event never enters the array and no lock state will explain the missed schedule.

FAQ

Does clearing the doing_cron transient always fix a missed schedule?

No. It only helps when the lock is stale. If the lock is held by a running process, clearing it can cause a second concurrent run. If the ready queue is empty, clearing the lock changes nothing.

Why does the editor say Missed Schedule instead of showing an error?

The label reflects that the scheduled publish event did not fire. It does not distinguish between a lock problem, a loopback problem, and a missing event. That distinction is what the diagnostic sequence above is for.

What is WP_CRON_LOCK_TIMEOUT set to?

The retrieved reference for spawn_cron() uses the constant but does not state its value. Read it from your install with wp eval 'var_dump( WP_CRON_LOCK_TIMEOUT );' rather than assuming a number.

Does the 6.9.0 shutdown change affect missed schedules?

It changes where the spawn attempt happens in the request lifecycle. Under the default path, _wp_cron() runs at shutdown, so the loopback fires after the HTML is flushed. Under ALTERNATE_WP_CRON, it runs at wp_loaded, which the documentation notes increases TTFB. If you are correlating slow pages with missed schedules, check which branch your install is on.

Can duplicate cron events cause missed schedules?

They can make the ready queue harder to reason about and inflate the cron option. The wp_schedule_event() documentation warns that mismatched arguments cause WordPress to treat events as different, leading to duplicates and database performance issues. Check for duplicates before concluding the lock is the cause.

Sources