Skip to content

fix(inertia-sails): ensure locals are accessible via locals.xxx in EJS templates#189

Merged
DominusKelvin merged 1 commit into
developfrom
feat/fix-locals-ejs-scoping
Mar 2, 2026
Merged

fix(inertia-sails): ensure locals are accessible via locals.xxx in EJS templates#189
DominusKelvin merged 1 commit into
developfrom
feat/fix-locals-ejs-scoping

Conversation

@DominusKelvin
Copy link
Copy Markdown
Member

@DominusKelvin DominusKelvin commented Mar 2, 2026

Summary

  • Fixes a bug where <%= locals.title %> in EJS templates always returned undefined, causing fallback defaults to render instead of dynamic values passed via the locals API

The Bug

When an action returns dynamic locals:

return {
  page: 'courses/view',
  props: { course },
  locals: { title: course.title, description: course.excerpt }
}

And the EJS template uses the documented locals.xxx pattern:

<title><%= locals.title || 'My App' %></title>

The rendered HTML always showed the fallback value (My App) — the dynamic value was silently ignored.

Root Cause

Sails's default EJS renderer (default-view-rendering-fn.js) creates an options.locals = {} object to hold internal helpers (blocks, layout, partial). EJS compiles templates into a function wrapped with with(data) { ... }. Inside this with block:

  • title resolves to data.title → the dynamic value ✓
  • locals resolves to data.locals → the Sails-created object (only has blocks, layout, partial) ✗
  • locals.titledata.locals.titleundefined → falls back to default ✗

The with statement causes locals to resolve to the data.locals property (the nested Sails object) rather than the locals function parameter (which contains all the actual view data).

The Fix

Pre-populate data.locals with user-provided locals by passing them as a nested locals key alongside the spread top-level properties:

// Before
res.view(rootView, { page, ...allLocals })

// After
res.view(rootView, { page, ...allLocals, locals: { ...allLocals } })

This ensures data.locals already contains the user's values when Sails's renderer checks if (!options.locals) — it finds our pre-populated object instead of creating an empty one. The renderer then adds its internal helpers (blocks, layout, partial) to the same object, so both user values and Sails internals coexist.

Test plan

  • Verify <%= locals.title %> renders dynamic values from action-level locals
  • Verify <%= locals.description %> renders dynamic values from sails.inertia.local()
  • Verify <%= locals.ogImage %> renders dynamic values from sails.inertia.localGlobally()
  • Verify fallback values still work when no locals are set (<%= locals.title || 'Default' %>)
  • Verify <%- shipwright.styles() %> and <%- shipwright.scripts() %> still work
  • Verify EJS partial() and layout() helpers still work

Closes #188

…S templates

Sails's default EJS renderer creates an `options.locals` object for internal
helpers (blocks, layout, partial). EJS wraps templates in `with(data) { ... }`,
which causes `locals` inside the template to resolve to `data.locals` (the
Sails-created object) rather than the function parameter containing the actual
view data. This made `<%= locals.title %>` always return undefined.

Fix by pre-populating `data.locals` with user-provided locals so they survive
the `with` scoping.

Closes #188
@DominusKelvin DominusKelvin merged commit 8835643 into develop Mar 2, 2026
4 checks passed
@DominusKelvin DominusKelvin deleted the feat/fix-locals-ejs-scoping branch March 2, 2026 17:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(inertia-sails): locals not accessible via locals.xxx in EJS templates

1 participant