<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Victor Nkachukwu</title>
    <description>The latest articles on DEV Community by Victor Nkachukwu (@victorcyril).</description>
    <link>https://dev.to/victorcyril</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4138204%2Fca7a9b41-f71f-4473-985a-fa84a6972cdb.png</url>
      <title>DEV Community: Victor Nkachukwu</title>
      <link>https://dev.to/victorcyril</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/victorcyril"/>
    <language>en</language>
    <item>
      <title>Chromium will not page-break inside a column flexbox</title>
      <dc:creator>Victor Nkachukwu</dc:creator>
      <pubDate>Tue, 22 Sep 2026 19:37:30 +0000</pubDate>
      <link>https://dev.to/resumarc/chromium-will-not-page-break-inside-a-column-flexbox-9a0</link>
      <guid>https://dev.to/resumarc/chromium-will-not-page-break-inside-a-column-flexbox-9a0</guid>
      <description>&lt;p&gt;&lt;a href="https://resumarc.com" rel="noopener noreferrer"&gt;Resumarc&lt;/a&gt; is a resume builder. Every template in it is a&lt;br&gt;
single React component that draws three things: the card in the gallery, the&lt;br&gt;
live preview in the editor, and the printed PDF. There is no separate print&lt;br&gt;
stylesheet. What you arrange on screen is what comes out of the file, because it&lt;br&gt;
is literally the same component rendered at a different size.&lt;/p&gt;

&lt;p&gt;That constraint is the good part of the design and it is also how we found the&lt;br&gt;
worst bug we have shipped.&lt;/p&gt;
&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;While testing exports before launch we downloaded a two-page resume. Page one had&lt;br&gt;
the header and then an empty main column. Page two had all the content. Page&lt;br&gt;
three was blank.&lt;/p&gt;

&lt;p&gt;It would not reproduce on the next document. Then it did, with the original one,&lt;br&gt;
in one template. Then five more templates did the same thing with that same&lt;br&gt;
document, and a dozen did not. Changing the font size&lt;br&gt;
fixed it. Changing it back broke it again, in a different place.&lt;/p&gt;

&lt;p&gt;Anything that moves when you change a font size is a layout bug, and anything&lt;br&gt;
that only happens on paper is a fragmentation bug. Fragmentation is the CSS term&lt;br&gt;
for what happens when a box has to be split across pages, and it is a part of the&lt;br&gt;
spec that browsers implement unevenly and quietly.&lt;/p&gt;
&lt;h2&gt;
  
  
  The cause
&lt;/h2&gt;

&lt;p&gt;Chromium will not place a page break in the space between the items of a column&lt;br&gt;
flexbox.&lt;/p&gt;

&lt;p&gt;Not "prefers not to". It moves the entire flex container to the next page&lt;br&gt;
instead. So a column laid out like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-col gap-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Section&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"summary"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Section&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"workHistory"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Section&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"education"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is, as far as pagination is concerned, one indivisible block. If the page&lt;br&gt;
boundary lands inside one of those &lt;code&gt;gap-4&lt;/code&gt;s, or in the margin under a section,&lt;br&gt;
Chromium gives up on splitting and pushes the whole column onto the next page.&lt;br&gt;
The first page keeps the header, which is outside the column, and is otherwise&lt;br&gt;
empty. The document then runs one page longer than it needs to, which is where&lt;br&gt;
the trailing blank page came from.&lt;/p&gt;

&lt;p&gt;That is why it looked random. Whether it happened depended on where the page&lt;br&gt;
boundary fell, which depended on the length of the content, which depended on the&lt;br&gt;
font size and the template. Six templates out of the set had geometry that put a&lt;br&gt;
boundary in a gap for that particular document.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;A grid fragments between its rows, and through its row gaps, the way normal block&lt;br&gt;
flow does. So every box in every document renderer that stacks its children&lt;br&gt;
vertically became a grid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"grid grid-cols-1 content-start gap-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole fix. It is one line per box, and there were a few hundred boxes&lt;br&gt;
across 130+ resume layouts, 20+ cover-letter layouts and the shared components&lt;br&gt;
they are built from. The mapping was mechanical:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column flexbox&lt;/th&gt;
&lt;th&gt;Grid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;flex flex-col gap-N&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;grid grid-cols-1 content-start gap-N&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;items-center&lt;/code&gt; / &lt;code&gt;items-start&lt;/code&gt; (cross axis)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;justify-items-center&lt;/code&gt; / &lt;code&gt;-start&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;justify-center&lt;/code&gt; / &lt;code&gt;justify-between&lt;/code&gt; (main axis)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;content-center&lt;/code&gt; / &lt;code&gt;content-between&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a &lt;code&gt;flex-1&lt;/code&gt; child filling the rest of the column&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;grid-rows-[auto_1fr]&lt;/code&gt; on the container&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two grid-specific classes are doing real work, and neither is decoration.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;content-start&lt;/code&gt; stops the rows stretching to fill a container taller than its&lt;br&gt;
content. A sidebar next to a longer main column is exactly that container, so&lt;br&gt;
without it every section in the sidebar grows a little and the spacing drifts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;grid-cols-1&lt;/code&gt; is more interesting than it looks. Tailwind compiles it to&lt;br&gt;
&lt;code&gt;minmax(0, 1fr)&lt;/code&gt;, and that &lt;code&gt;0&lt;/code&gt; minimum is the &lt;code&gt;min-width: 0&lt;/code&gt; that a stretched flex&lt;br&gt;
item used to get for free. Without it, an unbreakable string — and a resume is&lt;br&gt;
full of them, because people put profile URLs in their contact block — sets the&lt;br&gt;
column's minimum width and pushes the whole layout wider. So the migration fixed a&lt;br&gt;
second bug that had been papered over with &lt;code&gt;truncate&lt;/code&gt; in a few places.&lt;/p&gt;

&lt;p&gt;Two things stayed flex on purpose. &lt;code&gt;flex-col-reverse&lt;/code&gt; has no grid equivalent,&lt;br&gt;
because grid has no reversed flow, and a couple of entry variants use it to put&lt;br&gt;
dates above a job title while keeping the source order for screen readers. And any&lt;br&gt;
row that wraps stays &lt;code&gt;flex flex-wrap&lt;/code&gt;. Neither of those holds sections, so neither&lt;br&gt;
is on the pagination path.&lt;/p&gt;
&lt;h2&gt;
  
  
  Making it stay fixed
&lt;/h2&gt;

&lt;p&gt;A migration you can undo by typing three characters is not finished. The rule is&lt;br&gt;
enforced now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Literal[value=/(?:^|&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;s)flex-col(?:&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;s|$)/]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A document renderer stacks with `grid grid-cols-1 content-start`, not &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;`flex flex-col` — Chromium cannot page-break between column-flex items.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;scoped to the three directories that hold document renderers, with&lt;br&gt;
&lt;code&gt;flex-col-reverse&lt;/code&gt; deliberately still allowed. There is one occurrence of the&lt;br&gt;
string &lt;code&gt;flex-col&lt;/code&gt; left in those directories today and it is inside a comment.&lt;/p&gt;

&lt;p&gt;Alongside it, a test renders every layout with a fully populated document and&lt;br&gt;
asserts the structural invariants the PDF depends on. The valuable thing that&lt;br&gt;
test taught us is that &lt;strong&gt;a sweep which passes on the first run has not&lt;br&gt;
been verified.&lt;/strong&gt; Ours went green across every layout immediately, which felt like&lt;br&gt;
success and was actually a bug in the assertion. Injecting a deliberate violation&lt;br&gt;
into one template failed exactly one layout and named the offending element, and&lt;br&gt;
the template was then restored. Now the green means something.&lt;/p&gt;
&lt;h2&gt;
  
  
  The other half: margins that repeat per page
&lt;/h2&gt;

&lt;p&gt;Once the columns fragmented correctly, the next problem was the page margin.&lt;/p&gt;

&lt;p&gt;A resume template often paints its sidebar. A painted column has to run to the&lt;br&gt;
edge of the paper on every page, while its &lt;em&gt;text&lt;/em&gt; stops short of the edge on every&lt;br&gt;
page. A &lt;code&gt;@page { margin }&lt;/code&gt; cannot do that, because it reserves space outside the&lt;br&gt;
box, so the paint stops short too and you get a white band across the top of every&lt;br&gt;
page after the first. Padding on the document's own box does not work either:&lt;br&gt;
Chromium re-applies the box's top padding at each break, but the background still&lt;br&gt;
starts where the box starts.&lt;/p&gt;

&lt;p&gt;What does work is putting the margin &lt;em&gt;inside&lt;/em&gt; the column as a transparent border,&lt;br&gt;
and telling Chromium to repeat it on every fragment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-page-column&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12mm&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12mm&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background-clip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;border-box&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;box-decoration-break&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;margin-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-12mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;box-decoration-break: clone&lt;/code&gt; is the load-bearing line. By default a fragmented&lt;br&gt;
box's borders are drawn only at its true start and end; &lt;code&gt;clone&lt;/code&gt; draws them on&lt;br&gt;
every fragment, so each page gets 12mm of transparent border at the top and&lt;br&gt;
bottom. &lt;code&gt;background-clip: border-box&lt;/code&gt; keeps the paint running underneath that&lt;br&gt;
border, so the sidebar reaches the paper's edge while only its content stops&lt;br&gt;
short.&lt;/p&gt;

&lt;p&gt;The negative &lt;code&gt;margin-top&lt;/code&gt; is the trick that makes the first page right. A header&lt;br&gt;
band or a photo is supposed to sit hard against the top of page one, with no gap.&lt;br&gt;
Margins are &lt;em&gt;not&lt;/em&gt; cloned onto fragments, so a single negative top margin cancels&lt;br&gt;
the border on the first fragment only and leaves it in place on every page after.&lt;br&gt;
One declaration, two different behaviours, which is exactly the kind of thing you&lt;br&gt;
find by reading the fragmentation spec rather than by guessing.&lt;/p&gt;

&lt;p&gt;One warning that cost us an afternoon: pull up the &lt;em&gt;box&lt;/em&gt;, not its first child. For&lt;br&gt;
a single column they are the same thing. For a row of cells they are not, and only&lt;br&gt;
the first cell rises while the ones beside it sit a gap lower.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was the shared-renderer constraint worth it?
&lt;/h2&gt;

&lt;p&gt;Yes, and not for the expected reason.&lt;/p&gt;

&lt;p&gt;The assumption was that the win would be effort, one component instead of two. The real win is&lt;br&gt;
that it makes a whole category of bug impossible to ship quietly. If the preview&lt;br&gt;
and the PDF were separate code paths, this bug would have been a PDF-only defect&lt;br&gt;
that nothing on screen could reveal, and the fix would have been a print-only&lt;br&gt;
override that drifts from the screen version over the following year. Because&lt;br&gt;
there is one box model, the fix had to be correct on screen too, and could be&lt;br&gt;
verified by looking at a page instead of by exporting a file.&lt;/p&gt;

&lt;p&gt;It also means the constraint is load-bearing rather than aspirational. The ESLint&lt;br&gt;
rule and the layout sweep are not hygiene. They are the things that keep 150+&lt;br&gt;
templates from silently diverging into two rendering systems.&lt;/p&gt;

&lt;p&gt;The PDFs themselves are produced by handing the same page to Chromium through&lt;br&gt;
Gotenberg with &lt;code&gt;emulatedMediaType=print&lt;/code&gt; and &lt;code&gt;printBackground=true&lt;/code&gt;, which is the&lt;br&gt;
last piece of why this works: it is the same engine that laid the page out on&lt;br&gt;
screen, so there is nothing to reconcile.&lt;/p&gt;

&lt;p&gt;If you want to see what the layouts actually look like, they are at&lt;br&gt;
&lt;a href="https://resumarc.com/resume-templates" rel="noopener noreferrer"&gt;resumarc.com/resume-templates&lt;/a&gt;. Happy to&lt;br&gt;
answer anything about the rendering in the comments.&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>react</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
