Skip to content

feat(inertia): link server page props to the client that reads them - #1661

Open
ferrine wants to merge 2 commits into
colbymchenry:mainfrom
ferrine:feat/inertia-prop-boundary
Open

feat(inertia): link server page props to the client that reads them#1661
ferrine wants to merge 2 commits into
colbymchenry:mainfrom
ferrine:feat/inertia-prop-boundary

Conversation

@ferrine

@ferrine ferrine commented Aug 31, 2026

Copy link
Copy Markdown

Branch: ferrine:feat/inertia-prop-boundarymain
Size: 9 files, +1038 · Tests: inertia-prop-boundary.test.ts (29),
inertia-props.test.ts (13)

Why

Inertia (Laravel, Rails, Phoenix) renders a page by handing a server-side prop
map
to a client component. There is no REST schema and no GraphQL document:
the contract is that map, and it is written twice — once as the server map,
once as a client destructure or type. Nothing in either half references the
other, so the boundary is invisible to a graph built from calls and imports, and
three ordinary questions have no answer:

  • this prop is emitted — is it read anywhere? (a dead prop costs a query and
    payload on every render)
  • this prop is read — is it still emitted? (renders as undefined, silently)
  • I am renaming this prop — what else must change in the same commit?

Worse, when the server camelizes its keys the two halves do not even share a
spelling: the server emits user_display_name, the client reads
userDisplayName, so grepping either finds exactly one side. That is what makes
it a resolver problem rather than something search can answer.

What changed

A framework resolver plus a synthesis pass. Each prop a server hands to a page
becomes a symbol, linked to the code in the page component that actually reads
it.

No configuration and no global state. Rather than read the adapter's
camelize setting out of project config — which would have to be discovered once
and then carried into per-file extraction — a prop carries both candidate
spellings and the consumer scan records which one matched. That works unchanged
for Laravel and Rails (verbatim keys) and for Phoenix with camelize_props: true, and it cannot silently mis-link if a project's setting is not what we
assumed. Phoenix.Naming.camelize/2 has several divergences from a naive
snake→camel (acronyms, digits, preserve_case); those are implemented and
tested individually.

A prop counts as used only when a real function or value reads it. A type
or interface that merely declares the field does not count — that is what keeps
a never-rendered prop visible instead of appearing used by its own type
declaration. The distinction is per symbol, not per file, so a module exporting
both payload types and runtime helpers is treated correctly as both. Test files
are deliberately still counted: a rendered-page test is sometimes the only thing
that reads a field.

Two detection fixes folded in, both found on a real Phoenix app

  • Detection read manifests at the repository root only. Inertia spans a
    server and a client, so the app that uses it is frequently not the root of
    the repo — a mix.exs beside its own assets/, or a package.json under a
    client workspace, with the root holding only tooling. Reading the root alone
    meant the whole feature stayed silently switched off on exactly the layout
    Inertia projects tend to have. Manifests are now looked for anywhere in the
    index, by filename, capped so a repo that does not use Inertia does not pay.
  • The Phoenix pattern required the connection as a positional argument.
    Idiomatic Elixir pipes it — conn |> render_inertia("Page", %{...}) — so the
    call carries only the page and the props and matched nothing. The leading
    argument is now optional, with quotes excluded from it so the page name is not
    mistaken for one.

Known limit (stated so it isn't a surprise)

Extraction reads a literal map at the render call. A project that assembles
props anywhere else — builder functions that return maps, or a wrapper that
assigns keys one at a time before rendering — has no literal map adjacent to any
render call, and nothing is extracted. That is a real gap rather than a bug in
this PR; closing it needs either a project-supplied hint or interprocedural data
flow, and neither belongs here.

Notes for review

A no-op on any repo without an Inertia adapter in a manifest.

ferrine and others added 2 commits August 31, 2026 09:18
Inertia (Laravel, Rails, Phoenix) renders a page by handing a server-side
prop map to a client component. There is no REST schema and no GraphQL
document — the contract IS that map, and it is written twice, once as
the server map and once as a client destructure or type, with neither
half referencing the other. The boundary is invisible to a graph built
from calls and imports, so three ordinary questions have no answer: is
this emitted prop read anywhere, is this read prop still emitted, and
what else must change when I rename it.

When the adapter camelizes, the two halves do not even share a spelling:
the server emits `user_display_name` for a client reading
`userDisplayName`, so a grep for either finds exactly one side. That is
what makes it a resolver problem rather than something search can do.

Server prop keys become nodes (named as the server wrote them, with the
client spelling in the signature), and a synthesis pass links the symbols
in the page component that read them.

WHICH SYMBOLS COUNT IS THE FEATURE. A scan that reports a field used the
moment its name appears in the page's file counts the `interface Props`
that DECLARES it — so every correctly typed field is "used" by its own
declaration, nothing is ever orphaned, and only sloppy untyped fields
ever surface. The discriminator is therefore per-SYMBOL, not per-file:
each occurrence is attributed to the innermost symbol whose range
contains it, and a type-level declaration is not a consumer of the field
it declares while a function or value that reads it is. One module can
hold both halves — payload declarations plus runtime helpers imported as
a value namespace — and any path- or extension-based rule gets that file
wrong in one direction or the other, silently. Test files are
deliberately NOT excluded: a rendered-page test is sometimes the only
thing asserting a field, because content behind a portal never reaches a
server-rendered string.

No configuration and no global state. Rather than discover the adapter's
camelize setting and carry it into per-file extraction, a prop offers
BOTH candidate spellings and the pass records which matched — correct for
Laravel and Rails (verbatim) and Phoenix (camelized) alike, and unable to
mis-link if a project's setting is not what we assumed.

The camelize transform is `Phoenix.Naming.camelize/2`, implemented
faithfully rather than as a snake-to-camel regex, and pinned case by
case. It diverges on a leading underscore, a repeated underscore, a
trailing underscore, a leading acronym (only the FIRST character
lowercases) and an underscore before an uppercase letter (which stays
literal), while an underscore before a DIGIT is dropped. Every
divergence yields a plausible-LOOKING client name, so a mismatch reads
as "this field is never drawn" rather than "the transform is wrong".
`preserve_case/1` is honoured.

Only LITERAL, top-level map keys are read. An adapter transforms every
key at every depth with no distinction between a schema key and a DATA
key, so a map keyed by a user-supplied name is camelized exactly like a
field name; those are dynamic edges, and inventing a name for one
produces a confident wrong link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
…r form

Two independent reasons the prop boundary stayed empty on real projects,
both of which make the feature look absent rather than broken.

Detection read dependency manifests at the project root only. Inertia spans
a server and a client, so the app that uses it is frequently not the root of
the repository it lives in — a `mix.exs` beside its own `assets/`, or a
`package.json` under a client workspace, with the root holding only tooling.
Manifests are now looked for anywhere in the index, by filename, capped so a
repo that does not use Inertia does not pay for the check.

The Phoenix pattern also required the connection as a positional argument,
`render_inertia(conn, "Page", %{...})`. Idiomatic Elixir pipes it, so the
call carries only the page and the props and matched nothing. The leading
argument is now optional, with quotes excluded from it so the page name
itself is not mistaken for one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Bsi9EH64kMisnik4E1gf7
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.

1 participant