For months the converter had a bug I could not see: every box shadow it produced was being thrown away. Not rendered wrong — discarded. The conversion succeeded, the import succeeded, the elements arrived, and the shadows were simply not there.
No exception. No console warning. No failed request. Nothing in a log. If you had not built the page yourself you would not know anything was missing; you would just think the conversion was mediocre.
This is worth writing down because the shape of the problem is general. Elementor is only the specific case. The general case is: you write into a system that ignores keys it does not recognise, and a typo or a stale assumption turns into permanent, silent data loss.
Why it is invisible
Elementor stores each element as JSON — an element type plus a settings object. When it renders, it walks its registered controls and asks the settings object for each one. A key that does not correspond to a registered control is never looked up. It is not rejected. It is simply never read.
That is a reasonable design: old data survives plugin updates, and unknown keys from a newer version do not break an older one. It is also why a wrong key name produces exactly zero feedback. With a validating API a wrong key is an error at write time, caught by the next test run. With an ignoring API it is silence, discovered by a user months later, if ever.
Why you cannot simply look the names up
The obvious answer is to check the control names against the documentation. The problem is that many of them are not written down anywhere — they are generated.
Elementor has group controls: reusable clusters like typography, border, background, box shadow. You attach one to a widget with a name, and the individual control IDs are composed at runtime — the group name, an underscore, then each field name. So a typography group registered as title produces the control title_font_size. There is no file in which that string appears. You cannot grep for it. You have to know the rule and apply it.
This is exactly the situation where memory, forum answers and old blog posts are worse than useless: they are confidently specific, frequently wrong, and the API will not correct you.
The bug: a shadow is one control, not five
The converter had been writing seven keys for a shadow — one to switch it on, then colour, horizontal, vertical, blur, spread and position as separate values. It looks careful. It was almost entirely wrong.
The box-shadow group registers exactly two fields. The shadow itself is one composite control whose value is a single object holding horizontal, vertical, blur, spread and colour together; the second field is the outline/inset position. Add the popover toggle and the real key set is three names, all of them prefixed twice over because the group is itself named box_shadow.
Of the seven keys being written, five did not exist. The sixth had the right name but was being given the string "yes" instead of the object it expects. The seventh was close but missing a prefix.
One detail no amount of guessing would have produced: the value for “Outline” is a single space, not an empty string. That is in the options array and nowhere else.
The second bug: the right name that still did nothing
The same review turned up flex_grow and flex_shrink, written on elements with an explicit width to stop a flex parent stretching or squeezing them. Wrong again — those controls live in a group registered under a different name, so they needed a prefix.
But fixing the prefix would still not have worked, and this is the part worth remembering. Both fields carry a condition: they only apply when the group’s size control is set to “custom”. Conditions gate CSS generation, not just panel visibility. A correct key name is necessary and not sufficient.
The actual fix turned out to be simpler than either attempt, and it was sitting in the source the whole time: the size control has a “none” option that means exactly “do not grow, do not shrink” — one unconditional control instead of three conditional ones. Reading the source did not just fix the bug, it produced a better implementation than the one being aimed for.
Auditing the rest of it
Two bugs found by reading. There were ninety-odd other keys nobody had read. So the next step was mechanical: parse the official plugin source, build the authoritative set of control names for each widget, and diff it against every key the converter can emit.
The audit script gave four wrong answers before it gave a right one. Each failure generalises:
- The name is often on the next line. Control registrations frequently put the name on the line after the call. A single-line pattern missed them, and every widget appeared to emit five invalid keys — the entire Advanced tab. A checker that flags a standard control as unknown is not reporting a bug, it is reporting its own blind spot.
- Two idioms for the same thing. One group builds its fields under one variable name, another uses a different one. Matching only the first silently dropped whole groups — including, with some irony, the exact group under investigation.
- Argument arrays nest. Stopping the match at the first closing bracket truncated at the first nested array and skipped the registration entirely. That is how several perfectly valid layout keys first showed up as “invalid”.
- A wide capture window consumes. Fixing the previous point by grabbing a large window meant each match swallowed any registrations inside it. One widget’s known-control count dropped from 281 to 60 and the diff still reported success.
And one more, specific to this codebase: the Button widget’s file contains zero control registrations. They all live in a trait. The first run reported Button as having no controls at all.
Notice the direction of those errors. Three produced false alarms — annoying, but self-correcting: you investigate and find the tool is wrong. One produced a false pass, which is the dangerous kind. Fewer known-good names means fewer reported problems; the failure mode is silent and reassuring.
The check that makes the others trustworthy
Which leads to the one idea worth keeping from the whole exercise.
A checker that reports “all clean” is worth nothing until you have proved it is capable of reporting “not clean”.
So the audit ends with a negative control: it feeds the five known-bad key names back in and fails the entire run if any of them are accepted. If the extractor breaks later — a widened pattern, a skipped file, a bad cache — that assertion goes red before the “everything passed” line gets a chance to lie.
Two more anchors go with it: a list of control names that are certain to exist must all be found, and the total count must never silently shrink. That second one is what would have caught the 281-to-60 collapse.
Making it stay fixed
Final state once the diff came back clean: 205 keys checked across nine widget types, none of them unknown. Then a continuous-integration job so it stays that way — it converts a page containing a shadow and asserts on the output that the composite key is present and that none of the old separate names have come back.
Not a unit test of a pure function. An assertion on the artefact that actually ships, checking the exact mistake that already happened once.
The general version
If you write into anything that ignores unknown keys — a page builder, a CMS meta table, an analytics payload, a feature-flag blob, half the configuration formats in existence — then:
- Derive the valid key set from the source of truth, not from memory or documentation. Documentation describes intent; generated names only exist at runtime.
- Diff what you emit against it mechanically. Ninety keys is not reviewable by eye, and the wrong ones look exactly like the right ones.
- Distrust your checker first. When a run flags something you are confident about, the tool is wrong far more often than the code is.
- Add a negative control. Prove the checker can fail, or its passes mean nothing.
- Assert on the shipped artefact in CI, because this class of bug returns the moment someone refactors the emitting code.
The shadows work now. The more useful outcome was noticing that “no error” had been treated as evidence of correctness for months, on an API that is structurally incapable of producing one.