A renderer computes a few things for itself — the page number, the total page count, the date it ran. It fills them in only when the caller has not supplied a value, which is the polite version of that feature and the one most people would write.
Two defaults, each defensible
The renderer's rule is fill-in-if-absent. Three keys are reserved, and each is stamped only when the incoming data has nothing for it.
| Key | What the renderer stamps |
|---|---|
currentdate | the date the document was generated |
pagenumber | the page this element is drawn on |
totalpages | the page count of the finished document |
data.currentdate ??= formatDate(new Date())
data.pagenumber ??= page.index + 1
data.totalpages ??= doc.pageCount
Nullish assignment, so a caller who genuinely wants to pass a value wins.
The editor has a separate rule, written by someone else for a different reason: seed every placeholder the template mentions with a humanised version of its key, so no field in the data panel ever opens blank.
const humanise = (key) =>
key.split('_').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ')
humanise('buyer_name') // 'Buyer Name'
humanise('currentdate') // 'Currentdate'
Also reasonable: a panel of empty inputs tells you nothing about what the template expects.
The value that no screen ever showed
Put those together and a template with a current-date placeholder gets the literal string
Currentdate stored against currentdate. The renderer then does exactly what it
promised: the caller supplied a value, so it does not stamp one. The document goes out
with the word Currentdate where the date should be.
The humaniser only splits on underscores, so buyer_name comes out looking like a label
and currentdate becomes a single capitalised word. All three reserved keys are single
tokens, and all three seed into the same kind of nonsense.
The other half is worse. Reserved fields are filtered out of the data panel, because the
user is not meant to fill them — that is what reserving them means. So the seeded value
sat in stored data with no row in the interface. There was no screen anywhere in the
product on which the string Currentdate appeared before it turned up in a finished PDF.
The user could not have found this, could not have cleared it, and had no way to know it
was there.
Why the preview was right and the PDF was wrong
The preview path stripped reserved keys before sending. The generate path did not.
Same template, same stored data, two payload builders, and only one of them knew the rule. So the person checking their work saw the correct date on screen every time, which is precisely the circumstance under which nobody looks further. The preview did not merely fail to help — it certified the wrong output.
That is the general shape of it: when two call sites build the same payload, one of them is older, and the older one is missing whatever was learned since.
Strip at the boundary
There are two ways to fix a rule a caller forgot: tell every caller to remember it, or do it in the one place they all pass through.
const RESERVED = new Set(['currentdate', 'pagenumber', 'totalpages'])
export function stripReserved(data) {
return Object.fromEntries(
Object.entries(data).filter(([key]) => !RESERVED.has(key.toLowerCase())),
)
}
This runs at the render entry point, right after the incoming data is reshaped and before any placeholder is resolved. Not in the preview handler and the generate handler and the batch handler — once, below all of them, where a caller written next year inherits it without knowing it exists.
The verb matters too. Ignoring a reserved key leaves the bad value in place. Stripping removes it, so fill-in-if-absent gets the absence it was written for.
Validating on write would not have fixed it
The tempting alternative is to reject reserved keys at save time with a 422. Better error message, and it does not solve the problem.
Templates saved before the check still hold the seeded value. So do templates saved before a key joined the reserved list, and that list grows: the day you reserve a new name, every row already using it becomes wrong. Validation on write protects rows written after the validator shipped. Stripping on read fixes the history too, which is why it goes in even if you add the 422 as well.
One caveat. If you genuinely want to control the date — an
invoice dated into the previous GST period, say — that is an ordinary field with a name of
your choosing, invoice_date, bound like any other. Reserved means the renderer owns it.
The habit worth building
When you add a defaulting rule, find the other defaulting rules and ask what happens when both fire on the same key. Fill-in-if-absent and seed-everything-so-nothing-is-blank are each obviously correct in isolation, were written months apart by people solving unrelated problems, and are actively hostile to each other. No review would have caught it, because neither change was wrong.
And when a rule has to hold across several call sites, put it below them rather than in them. A convention every caller must remember is not a rule. It is a bug with a waiting period.