Skip to content

Limitations and differences

Runjucks targets Node.js and synchronous rendering. This page lists product-level gaps and quirks. For a maintainer-facing checklist, see NUNJUCKS_PARITY.md in the repository (including Testing model: full parity vs partial vs Runjucks-only JSON goldens). For throughput and caching (what is not a limitation), see Performance.

  • autoescape option — Nunjucks stores opts.autoescape and uses it in truthy checks for escaping. configure({ autoescape }) accepts boolean, string, number, null, or undefined and normalizes to a single engine flag: false, 0, "", null, and undefined (when explicitly passed) turn escaping off; other values (including non-empty strings like "html") turn it on. The Rust core still uses one boolean per environment — there is no per-filename extension switch like some mozilla.io examples describe. setAutoescape remains boolean-only.
  • Filesystem templates — call setLoaderRoot(absolutePath) on an Environment so named templates load from disk (relative paths under that root; .. traversal is rejected). Alternatively use setTemplateMap with an object of name → source strings, setLoaderCallback for a sync JS getSource(name) (no built-in http(s): loader in native code — see below).
  • HTTP(S) / URL templates (Node) — Nunjucks’ browser WebLoader fetches over HTTP; on Node, Runjucks keeps render / renderTemplate synchronous, so load sources outside the engine: await fetch(url) (or your HTTP client), build a Record<name, source>, then setTemplateMap(map) or setLoaderCallback((name) => map[name] ?? null). The package includes @zneep/runjucks/fetch-template-map (fetchTemplateMap(entries)) as a small helper that returns the map for setTemplateMap. Avoid blocking HTTP inside setLoaderCallback — prefetch, then render.
  • Express — optional helper require('@zneep/runjucks/express').expressEngine(app, opts?) registers app.engine for .njk (or your chosen ext) using setLoaderRoot from app.get('views') or opts.views. Rendering is synchronous; there is no async render callback like some Nunjucks setups.
  • No browser / UMD bundle as a first-class artifact — the runtime is a native addon for Node.
  • Async renderingrenderStringAsync and renderTemplateAsync return a Promise<string> and support async-only tags (asyncEach, asyncAll, ifAsync) as well as async filters (addAsyncFilter) and async globals (addAsyncGlobal). JS callbacks registered via these methods currently run synchronously on the main thread; the Promise-based API matches the Nunjucks surface for forward compatibility. See JavaScript API for usage.
  • No precompile / precompileString emitting JavaScript — the Rust engine parses templates to an internal AST and caches parses per environment / Template (see JavaScript API); there is no Nunjucks-style JS precompile artifact or browser bundle workflow.
  • addGlobal(name, value) accepts JSON-serializable values or a JavaScript function for Nunjucks-style {{ fn(…) }} calls (same thread as render; keyword arguments become a trailing plain object). See NUNJUCKS_PARITY.md (P1).
  • Render context (renderString(…, ctx)) is still JSON-shaped — you cannot pass live functions inside ctx and expect them to be invoked from templates (use addGlobal on the environment instead).
  • addExtension uses a declarative model: tag names, optional block end names, and a process callback. Nunjucks’ parser hook (parse(parser, nodes)) for custom AST nodes is not exposed.
  • import / from: only top-level macros are collected; side effects from running imported templates are not the same as Nunjucks in every edge case. Modifiers like with context on imports are parsed but not always equivalent to upstream.
  • include: Runjucks parses without context and with context on {% include %} (see native/crates/runjucks-core/tests/composition.rs and __test__/tags-extended.test.mjs in the repo). Stock nunjucks 3.2.4 does not accept those modifiers on include (it will parse-error). JSON conformance cases that must match nunjucks line-for-line (__test__/parity.test.mjs) therefore use plain includes and nested includes; behavior for include + context modifiers is covered by Rust tests and Node tests, not by the npm parity gate.
  • extends: dynamic parent names resolve at render time. A literal-only {% extends "…" %} chain is checked for cycles before render (in addition to runtime resolution); dynamic {% extends expr %} is not analyzed statically.
  • Some filters differ in edge cases (e.g. length on non-array objects, safe-string chaining vs Nunjucks). Prefer conformance tests or side-by-side checks for critical templates.
  • undefined vs null from JavaScript both map into the engine’s JSON-style value model — do not rely on distinct runtime behavior between them in templates.
  • Map / Set in render context are not automatically expanded into JSON objects — pass plain objects/arrays or use require('@zneep/runjucks/serialize-context').serializeContextForRender(obj) for an explicit Map/Set → JSON conversion at the boundary.
  • Regular expressions in templates use the Rust engine’s regex support, not full ECMAScript RegExp semantics; see NUNJUCKS_PARITY.md for flags and limitations.
  • Filter safeness (escape, safe, forceescape) and copy-on-escape behavior: tighten with targeted tests when a real template shows a gap; the repo does not guarantee bit-for-bit Nunjucks output for every edge chain.
  • Runjucks accepts array slice syntax without requiring a separate installJinjaCompat()-style shim (Nunjucks needs that for slices). A dedicated Jinja-compat API flag is not required for slices; other Jinja shims from Nunjucks are not mirrored as a single API.
  • @zneep/runjucks/install-jinja-compat exports installJinjaCompat() as a no-op so legacy require('…/install-jinja-compat') during migration does not throw; you do not need to call it for slices in Runjucks.