<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Victor Nkachukwu]]></title><description><![CDATA[Victor Nkachukwu]]></description><link>https://victorcyril.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Victor Nkachukwu</title><link>https://victorcyril.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 28 Sep 2026 16:01:55 GMT</lastBuildDate><atom:link href="https://victorcyril.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Chromium will not page-break inside a column flexbox]]></title><description><![CDATA[Resumarc is a resume builder. Every template in it is a single React component that draws three things: the card in the gallery, the live preview in the editor, and the printed PDF. There is no separa]]></description><link>https://victorcyril.hashnode.dev/chromium-will-not-page-break-inside-a-column-flexbox</link><guid isPermaLink="true">https://victorcyril.hashnode.dev/chromium-will-not-page-break-inside-a-column-flexbox</guid><category><![CDATA[CSS]]></category><category><![CDATA[React]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Next.js]]></category><dc:creator><![CDATA[Victor Nkachukwu]]></dc:creator><pubDate>Wed, 23 Sep 2026 17:13:36 GMT</pubDate><content:encoded><![CDATA[<p><a href="https://resumarc.com">Resumarc</a> is a resume builder. Every template in it is a single React component that draws three things: the card in the gallery, the live preview in the editor, and the printed PDF. There is no separate print stylesheet. What you arrange on screen is what comes out of the file, because it is literally the same component rendered at a different size.</p>
<p>That constraint is the good part of the design and it is also how we found the worst bug we have shipped.</p>
<h2>The symptom</h2>
<p>While testing exports before launch we downloaded a two-page resume. Page one had the header and then an empty main column. Page two had all the content. Page three was blank.</p>
<p>It would not reproduce on the next document. Then it did, with the original one, in one template. Then five more templates did the same thing with that same document, and a dozen did not. Changing the font size fixed it. Changing it back broke it again, in a different place.</p>
<p>Anything that moves when you change a font size is a layout bug, and anything that only happens on paper is a fragmentation bug. Fragmentation is the CSS term for what happens when a box has to be split across pages, and it is a part of the spec that browsers implement unevenly and quietly.</p>
<h2>The cause</h2>
<p>Chromium will not place a page break in the space between the items of a column flexbox.</p>
<p>Not "prefers not to". It moves the entire flex container to the next page instead. So a column laid out like this:</p>
<pre><code class="language-tsx">&lt;div className="flex flex-col gap-4"&gt;
  &lt;Section name="summary" /&gt;
  &lt;Section name="workHistory" /&gt;
  &lt;Section name="education" /&gt;
&lt;/div&gt;
</code></pre>
<p>is, as far as pagination is concerned, one indivisible block. If the page boundary lands inside one of those <code>gap-4</code>s, or in the margin under a section, Chromium gives up on splitting and pushes the whole column onto the next page. The first page keeps the header, which is outside the column, and is otherwise empty. The document then runs one page longer than it needs to, which is where the trailing blank page came from.</p>
<p>That is why it looked random. Whether it happened depended on where the page boundary fell, which depended on the length of the content, which depended on the font size and the template. Six templates out of the set had geometry that put a boundary in a gap for that particular document.</p>
<h2>The fix</h2>
<p>A grid fragments between its rows, and through its row gaps, the way normal block flow does. So every box in every document renderer that stacks its children vertically became a grid:</p>
<pre><code class="language-tsx">&lt;div className="grid grid-cols-1 content-start gap-4"&gt;
</code></pre>
<p>That is the whole fix. It is one line per box, and there were a few hundred boxes across 130+ resume layouts, 20+ cover-letter layouts and the shared components they are built from. The mapping was mechanical:</p>
<table>
<thead>
<tr>
<th>Column flexbox</th>
<th>Grid</th>
</tr>
</thead>
<tbody><tr>
<td><code>flex flex-col gap-N</code></td>
<td><code>grid grid-cols-1 content-start gap-N</code></td>
</tr>
<tr>
<td><code>items-center</code> / <code>items-start</code> (cross axis)</td>
<td><code>justify-items-center</code> / <code>-start</code></td>
</tr>
<tr>
<td><code>justify-center</code> / <code>justify-between</code> (main axis)</td>
<td><code>content-center</code> / <code>content-between</code></td>
</tr>
<tr>
<td>a <code>flex-1</code> child filling the rest of the column</td>
<td><code>grid-rows-[auto_1fr]</code> on the container</td>
</tr>
</tbody></table>
<p>The two grid-specific classes are doing real work, and neither is decoration.</p>
<p><code>content-start</code> stops the rows stretching to fill a container taller than its content. A sidebar next to a longer main column is exactly that container, so without it every section in the sidebar grows a little and the spacing drifts.</p>
<p><code>grid-cols-1</code> is more interesting than it looks. Tailwind compiles it to <code>minmax(0, 1fr)</code>, and that <code>0</code> minimum is the <code>min-width: 0</code> that a stretched flex item used to get for free. Without it, an unbreakable string — and a resume is full of them, because people put profile URLs in their contact block — sets the column's minimum width and pushes the whole layout wider. So the migration fixed a second bug that had been papered over with <code>truncate</code> in a few places.</p>
<p>Two things stayed flex on purpose. <code>flex-col-reverse</code> has no grid equivalent, because grid has no reversed flow, and a couple of entry variants use it to put dates above a job title while keeping the source order for screen readers. And any row that wraps stays <code>flex flex-wrap</code>. Neither of those holds sections, so neither is on the pagination path.</p>
<h2>Making it stay fixed</h2>
<p>A migration you can undo by typing three characters is not finished. The rule is enforced now:</p>
<pre><code class="language-js">// eslint.config.mjs
{
  selector: "Literal[value=/(?:^|\\s)flex-col(?:\\s|$)/]",
  message:
    "A document renderer stacks with `grid grid-cols-1 content-start`, not " +
    "`flex flex-col` — Chromium cannot page-break between column-flex items.",
}
</code></pre>
<p>scoped to the three directories that hold document renderers, with <code>flex-col-reverse</code> deliberately still allowed. There is one occurrence of the string <code>flex-col</code> left in those directories today and it is inside a comment.</p>
<p>Alongside it, a test renders every layout with a fully populated document and asserts the structural invariants the PDF depends on. The valuable thing that test taught us is that <strong>a sweep which passes on the first run has not been verified.</strong> Ours went green across every layout immediately, which felt like success and was actually a bug in the assertion. Injecting a deliberate violation into one template failed exactly one layout and named the offending element, and the template was then restored. Now the green means something.</p>
<h2>The other half: margins that repeat per page</h2>
<p>Once the columns fragmented correctly, the next problem was the page margin.</p>
<p>A resume template often paints its sidebar. A painted column has to run to the edge of the paper on every page, while its <em>text</em> stops short of the edge on every page. A <code>@page { margin }</code> cannot do that, because it reserves space outside the box, so the paint stops short too and you get a white band across the top of every page after the first. Padding on the document's own box does not work either: Chromium re-applies the box's top padding at each break, but the background still starts where the box starts.</p>
<p>What does work is putting the margin <em>inside</em> the column as a transparent border, and telling Chromium to repeat it on every fragment:</p>
<pre><code class="language-css">[data-page-column] {
  border-top: 12mm solid transparent;
  border-bottom: 12mm solid transparent;
  background-clip: border-box;
  box-decoration-break: clone;
  margin-top: -12mm;
}
</code></pre>
<p><code>box-decoration-break: clone</code> is the load-bearing line. By default a fragmented box's borders are drawn only at its true start and end; <code>clone</code> draws them on every fragment, so each page gets 12mm of transparent border at the top and bottom. <code>background-clip: border-box</code> keeps the paint running underneath that border, so the sidebar reaches the paper's edge while only its content stops short.</p>
<p>The negative <code>margin-top</code> is the trick that makes the first page right. A header band or a photo is supposed to sit hard against the top of page one, with no gap. Margins are <em>not</em> cloned onto fragments, so a single negative top margin cancels the border on the first fragment only and leaves it in place on every page after. One declaration, two different behaviours, which is exactly the kind of thing you find by reading the fragmentation spec rather than by guessing.</p>
<p>One warning that cost us an afternoon: pull up the <em>box</em>, not its first child. For a single column they are the same thing. For a row of cells they are not, and only the first cell rises while the ones beside it sit a gap lower.</p>
<h2>Was the shared-renderer constraint worth it?</h2>
<p>Yes, and not for the expected reason.</p>
<p>The assumption was that the win would be effort, one component instead of two. The real win is that it makes a whole category of bug impossible to ship quietly. If the preview and the PDF were separate code paths, this bug would have been a PDF-only defect that nothing on screen could reveal, and the fix would have been a print-only override that drifts from the screen version over the following year. Because there is one box model, the fix had to be correct on screen too, and could be verified by looking at a page instead of by exporting a file.</p>
<p>It also means the constraint is load-bearing rather than aspirational. The ESLint rule and the layout sweep are not hygiene. They are the things that keep 150+ templates from silently diverging into two rendering systems.</p>
<p>The PDFs themselves are produced by handing the same page to Chromium through Gotenberg with <code>emulatedMediaType=print</code> and <code>printBackground=true</code>, which is the last piece of why this works: it is the same engine that laid the page out on screen, so there is nothing to reconcile.</p>
<p>If you want to see what the layouts actually look like, they are at <a href="https://resumarc.com/resume-templates">resumarc.com/resume-templates</a>. Happy to answer anything about the rendering in the comments.</p>
]]></content:encoded></item></channel></rss>