The four layers
Two consequences follow from the order alone, and they are the source of most confusion:
- The backend engine never sees
[[ ]]. It only recognises{{ }}and@directives;[[ ]]is inert text to it and is passed through to the browser untouched. - The frontend engine never sees
{{ }}or@if. By the time the browser has the HTML, those are already rendered away. Anything left over is literal text and the frontend engine ignores it.
Order of execution
1
Server — resolve the page
Domain → funnel → page. The page HTML is loaded from the database exactly as you saved it.
2
Server — run the page-events graph (backend nodes)
The per-page node graph executes. Nodes that rewrite HTML do it here, on the raw saved source:
component_split_testfills a<split-test>container with the selected variant componentdynamic_containerfills a<dynamic-container>with a chosen componentpage_variantcan swap the whole page outscript_rule,url_redirect,add_tag,set_merchant, … run their side effects
This step happens before the backend template engine and long before the browser. That is the single most important fact on this page.
3
Server — expand platform tags and components
<component type="code"> and the builder’s component placeholders are resolved (each component recursively runs this entire pipeline, including its own template render). Then inline split tests, purchase tags ([BUY=], [UPSELL=], [QUICKBUY=], add-to-cart, subscription upsell), video tags, <product-item> / <collection-item>, order and date tags, {customer.*}, merchant tags, brand variables, quizzes, courses, and the checkout or cart processor (which is what seeds window.efScope.checkout).4
Server — render the backend template engine
Now, and only now,
{{ }} and the @ directives run: @extends/@block/@yield assemble the layout, @if/@elseif/@else, @foreach, @set, @component, variables and filters. Asset URLs are rewritten just before this.Because @extends can pull in markup that was not in the page source, purchase tags are swept a second time after the render.5
Server — prepare for the browser
Elements containing unresolved
[[ … ]] get data-ef-cloak so they are hidden until the frontend engine runs; src/srcset/poster attributes containing [[ ]] are deferred so the browser does not fetch a nonsense URL. The result is wrapped in the site layout, which injects the cloak stylesheet, the frontend page-events JSON, and the JS bundle. The response is sent.6
Browser — before JS runs
Every tag the frontend engine consumes —
template-if, template-else-if, template-if-else, template-else, template-foreach, template-vars, template-set, template-partial / partial — is display: none from the cloak stylesheet, as is anything carrying data-ef-cloak. Nothing an engine tag wraps is painted before the engine has decided what to keep. Containers are not hidden — they were already resolved on the server, so their content is final HTML.7
Browser — mount the frontend engine
On
DOMContentLoaded (deferred one frame on checkout pages so the cart plugin can populate scope first), initTemplateScope() runs: it makes window.efScope reactive, copies the URL query string into efScope.query, then applies directives in a fixed order — template-vars → template-set → template-partial → template-foreach → template-if → [[ ]] interpolation — and finally wires the ef-* bindings and event handlers.8
Browser — stay reactive
Any mutation of
window.efScope (or an explicit notifyScopeUpdated() / ef-scope-updated event) re-evaluates conditions, re-renders loops, and refreshes bindings. Frontend page-event nodes and window.ef scripts run in this phase too.Which syntax do I use for…
var.* is a backend facility
Brand variables (variables.json, the Variables screen, funnel overrides) belong to the backend template engine — the third layer, on the server. They are resolved with {{ var.x }} while the server renders, and they are deliberately not copied into window.efScope — the browser never receives the variable set, only the values the page chose to print. That is by design, not a missing feature: it keeps a brand’s full variable list (prices, internal codes, per-funnel overrides) off the wire, and it keeps {{ }} and [[ ]] on opposite sides of the response.
So [[ var.x ]] resolves to nothing — there is no var in browser scope to resolve it against.
When you do want a brand variable client-side, hand it over explicitly. The backend prints the value, the browser stores it under a name of your choosing:
| json quotes and escapes the value so strings, numbers and objects all land as valid JavaScript. Assign it before the engine mounts (an inline <script> in the page body is fine) and [[ offerName ]] renders on first paint; assign it later and the scope’s reactivity re-renders the bindings for you.
<template-component> does not exist
This one has to be stated plainly, because it appears in several older reference tables and the linter tag-balances it as though it were real:
The ways to include a component that do work:
In every case the component’s own HTML is then run through the full pipeline itself, so the component can use
{{ }}, @if, [[ ]] and the rest normally.
Containers are not a template engine
<split-test> and <dynamic-container> are slots. They contain no logic. They do not evaluate anything. They mark a position in the page and say “the page-events graph may replace this”.
The content model
A container’s inner content is the default. At request time exactly one of two things renders:- A test or a rule is bound to this container — the graph picks a component and that component’s HTML replaces the container, wrapper tag and all. For a split test the wrapper becomes a
divcarryingdata-sid/data-fnid/data-cidfor analytics; for a dynamic container it becomes adivkeeping the originalid. - Nothing is bound — no split test running, no graph, no matching rule — and the default content renders as-is, with the wrapper tag gone.
Known bug, fix in flight. In the current renderer an unbound container can survive into the response as a literal
<split-test id="…">…</split-test> element instead of being unwrapped. A real published page has been observed doing this. The default content is still visible (unknown elements render their children) but the wrapper survives, which shows up in view-source and can break block layout because unknown elements are inline-level. Some unbound paths already unwrap correctly; the remaining ones are being fixed to match the model above. Write your pages against the model, not against the current output.Why variants belong in the graph, not in the markup
Because only one of the two outcomes above can ever render, markup that lists several alternatives inside one container is always wrong. Two sibling components inside one container is not “two variants” — it is one default that happens to contain both, and if a test is running neither of them renders. There is no syntax for “pick one of these inline children”; that job belongs to the graph, which is also where the weights, the assignment cookie, the winner selection and the reporting live. A single component inside a container is fine — it is just the default content. If you genuinely want the alternatives written inline in the page, that is a different tag:<inline-split-test> with <variant name="…" weight="…"> children, which does select among its own children at request time and needs no graph.
A container inside a condition
This is the interaction that surprises people, and it fails the same way for both engines:@if runs in step 4 and <template-if> runs in step 7 — both strictly later. So on every single request:
- the graph runs, selects a variant, and writes the assignment cookie
- the visitor is enrolled in the test and the test id is added to the session’s split-test attribution list
- then the condition removes the resolved markup from the page
@if / <template-if> freely. If the whole conditional block is the thing you want to test, make the block itself the container.
Wrong vs right
Here is the mistake this page exists for, and what each error costs.Right — split-test the intro
quiz-intro-a and quiz-intro-b are saved components — each one is the complete replacement markup for that slot. Weights must total exactly 100.
Right — gate the card on the quiz step
Move the condition inside, so the container is resolved unconditionally and only its presentation is conditional:<template-if> inside each variant component so every variant is enrolled and hidden on the same terms.
Right — inline alternatives with no graph
When you want the alternatives in the page source and do not need component-level reporting:Right — a component include
Known gaps
Verified against the renderer. Flagged rather than papered over.<template-component>is documented but unimplemented. Several older reference tables list it as the frontend equivalent of@component. It is not — nothing resolves it. Use@component("code").- Unbound containers can leak their wrapper tag. See the note above; a fix is in progress.
- The renderer matches container attributes in any order, but the builder’s container scanner does not. The Page Events container dropdown is populated by scanning for
id="…"immediately followed byname="…", both double-quoted. Written any other way the container still fills at runtime but cannot be selected in the UI. See Dynamic content.