feat(inertia): link server page props to the client that reads them - #1661
Open
ferrine wants to merge 2 commits into
Open
feat(inertia): link server page props to the client that reads them#1661ferrine wants to merge 2 commits into
ferrine wants to merge 2 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Branch:
ferrine:feat/inertia-prop-boundary→mainSize: 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:
payload on every render)
undefined, silently)Worse, when the server camelizes its keys the two halves do not even share a
spelling: the server emits
user_display_name, the client readsuserDisplayName, so grepping either finds exactly one side. That is what makesit 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 weassumed.
Phoenix.Naming.camelize/2has several divergences from a naivesnake→camel (acronyms, digits,
preserve_case); those are implemented andtested individually.
A prop counts as used only when a real function or value reads it. A
typeor
interfacethat merely declares the field does not count — that is what keepsa 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
server and a client, so the app that uses it is frequently not the root of
the repo — a
mix.exsbeside its ownassets/, or apackage.jsonunder aclient 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.
Idiomatic Elixir pipes it —
conn |> render_inertia("Page", %{...})— so thecall 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.