Skip to content

Replace nested_form with RailsAdmin's own nested form implementation - #3756

Open
mshibuya wants to merge 5 commits into
masterfrom
nested-form/replacement
Open

mshibuya wants to merge 5 commits into
masterfrom
nested-form/replacement

Conversation

@mshibuya

@mshibuya mshibuya commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Replaces the nested_form dependency with RailsAdmin's own implementation. Closes #3723.

nested_form has been archived since 2021. This reimplements what RailsAdmin used it for without reading its source: the contract was reconstructed from RailsAdmin's own call sites and from the HTML a rendered form actually contains, so there is no derivative work here. The vendored copy of its jQuery plugin is removed unopened.

What this means for applications

An application that has not customized the nested form markup has nothing to do. nested_form false, the nested section and inline_add all behave as before, and so does the whole server-side parameter path.

The class names are unchanged too — .fields, .add_nested_fields, .remove_nested_fields, .tab-pane, .object-infos, data-nestedmany, data-nestedone — so existing styling keeps working, with one exception: both controls are now <button type="button"> rather than <a href="javascript:void(0)">, so a selector written as a.add_nested_fields needs the a dropped. In exchange, RailsAdmin's forms no longer need a javascript: URL allowed in your Content Security Policy.

A custom _form_nested_many or _form_nested_one partial needs one line changed:

- <%= form.fields_for field.name do |nested_form| %>
+ <%= form.nested_fields_for field do |nested_form| %>

link_to_add and link_to_remove keep their names and their arguments. Renaming them was considered and dropped: it would have cost every application that customized these partials a change, in return for nothing but tidier vocabulary. Both controls now decide for themselves whether to appear, so the conditions around them can go — #nested_fields_for is the only method this adds to the public surface.

Code that hooked the JavaScript needs updating: window.nestedFormEvents and the jQuery nested:fieldAdded / nested:fieldRemoved events are replaced by rails_admin.nested_field_added and rails_admin.nested_field_removed, CustomEvents dispatched on the row and carrying {field, association} and {field, group, destroyed}.

All of this is in docs/upgrading-to-4.md.

How it works now

A nested association used to be cloned from an HTML string, HTML-escaped into a data-blueprint attribute on a page-global <div> emitted after the form. It now renders an inert <template> as the last child of its own .tab-content, and the add button finds it by name within its control group.

Position carries what the global id was encoding, which is where most of the value is: nothing page-global is left to collide, a child's template rides along inside its parent's, and the markup is escaped once by ERB rather than escaped into an attribute and unescaped again by the browser.

Fixed along the way

  • Adding a row to a nested form shown in a modal built it from the form behind the modal. The row's inputs were named for the wrong model, so whatever was typed into it was dropped on save.
  • A row you had just added could not be removed once the form came back from a failed save, on an association configured with allow_destroy: false.
  • A nested form for an association that can reach itself — Mongoid's recursively_embeds_many, or a self-referential has_many — hung the request, climbing past 560 MB resident in seven minutes. One association may now repeat at most MAX_NESTED_FORM_RECURSION levels down a chain; a chain of distinct models stays unlimited. recursively_embeds_* had no integration coverage at all before.

Verification

ActiveRecord, Rails 8.1 1284 examples, 0 failures
Mongoid, Rails 8.1 1076 examples, 0 failures
composite_primary_keys, Rails 7.0 1284 examples, 0 failures

The three ActionView methods this hooks into are byte-identical between 7.0.8.4 and 8.1.3.1, so there is no version spread across >= 7.0, < 9.

🤖 Generated with Claude Code

@coveralls

coveralls commented Oct 6, 2026 •

Copy link
Copy Markdown

Coverage Status

coverage: 96.331% (+0.05%) from 96.285% — nested-form/replacement into master

mshibuya and others added 5 commits October 8, 2026 22:19
Before swapping nested_form's blueprint mechanism for inert <template> elements, write specs for the cases that mechanism is the only thing covering. Three were predicted to be broken; one of them was not.

Not a bug, now pinned: a deeply nested row added inside a freshly added, unsaved parent row. The inner add link names a page-global blueprint whose parent index is still the literal 'new_nested_field_tests' placeholder, so this looked like it had to produce a phantom second parent. It does not -- the parent prefix is resolved from the DOM at click time, and the saved records come out right. The spec stays as a regression test.

Pending, confirmed broken: a modal form and the page behind it both emit a blueprint under the same page-global id, so adding a row inside the modal takes its markup from the form behind it and the input is named for the wrong model; and a row the user added cannot be removed once the form comes back from a failed save and allow_destroy is false, because the destroy button is gated on the blueprint's child_index, which a re-rendered row does not carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rints

A nested association used to be cloned from an HTML string, HTML-escaped into a data-blueprint attribute on a page-global div emitted after the form. It now renders an inert <template> as the last child of its own .tab-content, and the association's add button finds it by name within its control group. Position carries what the global id used to: there is nothing page-global left to collide, a child's template rides along inside its parent's and needs no prefix arithmetic, and the markup is escaped once by ERB rather than escaped into an attribute and unescaped by the browser.

RailsAdmin::FormBuilder grows #nested_fields_for, #nested_add_button and #remove_button, and wraps Rails' own #fields_for_nested_model for the per-child .fields node. link_to_add and link_to_remove stay as deprecated aliases, since overriding _form_nested_many or _form_nested_one is a documented customization path; both map onto the new methods argument for argument. The vendored jQuery plugin and window.nestedFormEvents are gone, replaced by src/rails_admin/nested-form.js, which emits rails_admin.nested_field_added and rails_admin.nested_field_removed in the naming style ui.js and remote-form.js already use.

Two things the mechanism was the only thing covering are fixed: a nested form inside a modal no longer takes its rows from the form behind it, and a row the user added stays removable when the form comes back from a failed save with allow_destroy false. Both had pending specs from the previous commit.

The placeholder index is tagged by nesting depth (new_1_nested_field_tests) and never by association name: a name-tagged placeholder is a prefix of the ones in the templates nested inside it, and substituting the outer one would eat them. Both controls are now <button type='button'>, so no Content Security Policy has to admit a javascript: URL; the remove control needs its own UA chrome turned off in theming.scss.

The gemspec dependency and the dead remove_nested_one_fields handling go separately. Mongoid is not verified yet - nested_template_for builds its sample child with abstract_model.new, and embedded documents are the case most likely to object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing has required nested_form since the previous commit, so the gemspec dependency goes. The gem has been archived since 2021, which is what #3723 asked about. The lockfiles are not tracked - .gitignore excludes gemfiles/*.lock and .git/info/exclude excludes gems.locked - so regenerating them is a local step; bundle list no longer names the gem.

The remove_nested_one_fields class goes with it. Nothing has generated it since the has_one remove button was reworked, and git log -S shows the click handler in ui.js and the styles in theming.scss surviving three rewrites while waiting for markup that never came back.

Refs #3723

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A template renders one sample child, so an association that can reach itself - Mongoid's recursively_embeds_many, or a self-referential has_many with accepts_nested_attributes_for - has no terminating case. On master this hung the request, climbing past 560 MB resident in seven minutes and still growing; since the templates landed it raises SystemStackError instead. Neither is a form.

One association may now repeat at most MAX_NESTED_FORM_RECURSION times down a chain of nested forms. Any infinite chain has to revisit some association, there being finitely many of them, so capping the repeats of one association bounds every cycle - including one that closes through several distinct associations. Counting repeats rather than depth is what keeps FieldTest -> nested_field_tests -> deeply_nested_field_tests unlimited however deep it runs, which a flat depth cap would not have done.

The count comes off @object_name, which already spells the chain, so nothing has to be threaded through generate and a partial someone has overridden keeps working. Where no template is rendered the add button is not rendered either, so the UI does not offer something that quietly does nothing; nested_add_allowed? is the one place that decides, and inline_add folds into it.

recursively_embeds_* had no integration coverage at all - it appeared only inside unit specs defining throwaway classes - so the dummy app grows RecursiveFieldTest on Mongoid. ActiveRecord gets the same shape from the existing Category, whose table already carries parent_category_id, so no migration.

Refs #3723

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three audiences, because the configuration API did not change but everything around it did: applications that override _form_nested_many or _form_nested_one, applications that hooked window.nestedFormEvents or the jQuery nested:field* events, and applications whose CSS matches a.add_nested_fields now that both controls are buttons.

Refs #3723

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mshibuya
mshibuya force-pushed the nested-form/replacement branch from 6e499d0 to d31789f Compare October 8, 2026 13:37
@mshibuya
mshibuya marked this pull request as ready for review October 10, 2026 13:05
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.

nested_form uses unfrozen literal strings — will break in Ruby 4.0

2 participants