Skip to content

fix: make {formPrint} work with BootstrapRenderer - #132

Open
dakorpar wants to merge 3 commits into
masterfrom
fix/63-formprint-blueprint
Open

fix: make {formPrint} work with BootstrapRenderer#132
dakorpar wants to merge 3 commits into
masterfrom
fix/63-formprint-blueprint

Conversation

@dakorpar

@dakorpar dakorpar commented Aug 14, 2026

Copy link
Copy Markdown
Member

Closes #63.

Overlaps #111. @f3l1x already opened #111 against this issue, and it independently reaches the same conclusion on the first three points below. This PR is a superset — it also fixes the group duplication and the grid crash, which #111 does not touch — so I'd suggest landing this one and closing #111, or cherry-picking whichever half you prefer. Its plain-form, cloned-renderer and Bootstrap 5 test scenarios are adapted here with credit.

The reported exception (Runtime::renderformPrint()) was from the Latte 2 era and no longer occurs, but the feature is still broken on current master — it just fails later and differently. Nette\Forms\Blueprint, which backs {formPrint}, renders a dummy plain Nette form through a clone of the real form's renderer, and BootstrapRenderer assumes both the form and its controls are ours.

Reproduced before touching anything (Blueprint::generateLatte() is public, so no Latte install needed):

TypeError: BootstrapRenderer::attachForm(): Argument #1 ($form) must be of type
BootstrapForm, Nette\Forms\Form@anonymous given

What was wrong

Symptom Also in #111
attachForm() required a BootstrapForm TypeError — nothing rendered at all yes
renderControl() assumed getControl() returns Html TypeError — the dummy returns a bare {input …} string yes
renderLabel() returns early when getCaption() is null every {label …} silently missing from the blueprint yes
grouped controls tracked via an option the dummy forwards elsewhere every grouped control emitted twice no
BootstrapRow has no lookupPath() MemberAccessException on any form using addRow() no

The duplication is the interesting one. Blueprint's dummy controls forward getOption() to the control they wrap, so the renderer's _rendered mark was written to the dummy and read back off a different object — always false, so every grouped control was drawn again by the trailing pass. The renderer now remembers what it drew in an SplObjectStorage, which is identity-based and immune to that forwarding.

What changed

  • attachForm(Form $form); the single BootstrapForm-only access (showValidation) now goes through shouldShowValidation(), which calls the real getter rather than the magic property.
  • Non-Html control output is passed through untouched — there are no attributes to configure on a placeholder.
  • A string label is emitted raw via Html::el(), not nested — {label name/} already expands to a whole <label>. This keeps renderLabel(): Html intact; Add Blueprint/formPrint compatibility to BootstrapRenderer #111 widens the signature to Html|string instead, which works equally well but changes the public return type.
  • Rendered-control bookkeeping moved into the renderer (markRendered() / isRendered()), with __clone() giving a cloned renderer its own. RendererOptions::_RENDERED is unchanged — it is still written, and still honoured if you set it by hand.
  • lookupPath() / getLabel() / isRequired() added to FakeControlTrait.
  • Drive-by: BootstrapRow::getOption() warned on unset options instead of returning null as its own docblock promises. Reached through this same path.

Risk to existing behaviour

I probed this directly rather than reasoning about it. One earlier revision of this branch reused Nette's own 'rendered' option key to fix the duplication, which turned out to break assisted manual rendering: BaseControl::getControl() sets that key on every fetch, so renderControls() called without a preceding render() silently skipped any control whose html had been fetched. That is why the bookkeeping is identity-based now, and RenderedOptionTest::testAssistedRenderingIsNotDisturbedByFetchingControlHtml fails if anyone tries the shortcut again.

What remains, and is unavoidable for this fix: a subclass overriding attachForm(BootstrapForm $form) becomes a fatal error at class-declaration time, since a child may not narrow a parameter type. #111 has the same break. Anything that only calls attachForm() is fine.

renderLabel() now calls getLabel() before the caption check, so a control with no caption gets getLabel() called where it previously did not. Nette's own controls have no side effects there, and all 66 snapshot fixtures are byte-identical.

Result

<form n:name="myForm"><fieldset><legend>Personal</legend>
<div class="form-group row">
	{label name/}
	<div class="col-sm-9">{input name}
		<div class="invalid-feedback">{inputError name}<br></div>
	</div>
</div>
</fieldset></form>

Tests

tests/Rendering/BlueprintTest.php — plain forms, side-by-side wrappers, label nesting, group de-duplication, containers + hidden fields, grid rows, Bootstrap 5, and the renderer drawing a plain Nette\Forms\Form both directly and through a clone.

tests/Rendering/RenderedOptionTest.php — the bookkeeping: a control whose getControl() was called still renders (both under full render and under assisted manual rendering), rendering twice is idempotent, a grouped control appears once, and marking a control rendered by hand still skips it.

tests/Grid/BootstrapRowTest.php — extended with the fake-control surface: lookupPath() (asserting a fake control reports the same path a real sibling would), getLabel(), isRequired(), and getOption() returning null for an unset key without raising a warning.

Verified against origin/master's source rather than assumed: 6 of the new tests error there. make qa (PHPStan level 7 + codesniffer) and the full suite pass: 179 tests, 284 assertions.

Known limitation

A form built with the Bootstrap grid produces one spurious {input bootstrap_row_N} line per row. Blueprint walks everything $form->getControls() yields, BootstrapRow must implement Control to appear in the renderer's own loop, and Blueprint's dummy emits an {input …} placeholder for whatever it wraps — so the row cannot be filtered out without either coupling to Blueprint's anonymous dummy class or reworking how rows sit in the component tree. It no longer crashes; delete the line. Happy to chase it further if you think it's worth it.

🤖 Generated with Claude Code

Nette\Forms\Blueprint, which backs the {formPrint} macro, renders a dummy
plain Nette form through a clone of the real form's renderer. Four things
made BootstrapRenderer choke on it:

- attachForm() required a BootstrapForm, so render() threw a TypeError on
  Blueprint's dummy form. Widened to Form, with the one BootstrapForm-only
  access (showValidation) moved behind shouldShowValidation().
- renderControl() assumed getControl() returns Html; the dummy returns a
  bare '{input ...}' placeholder string. It is now passed through as-is,
  there being no attributes to configure on it.
- renderLabel() dropped the '{label ...}' placeholder, because the dummy
  carries no caption. The placeholder already expands to a whole <label>,
  so it is emitted raw rather than nested inside another one.
- BootstrapRow, being a fake control, is still walked by Blueprint like a
  real one, and had no lookupPath()/getLabel()/isRequired(). Added to
  FakeControlTrait.

Grouped controls also came out twice, because the renderer marked them with
its own '_rendered' option while Blueprint's dummy only forwards Nette's
'rendered' key to the wrapped control. RendererOptions::_RENDERED now uses
that same key, which is also the one BaseControl::getControl() sets.

BC note: the constant's *value* changed. Code using RendererOptions::_RENDERED
is unaffected; code using the literal '_rendered' is not.

Also fixes BootstrapRow::getOption() warning on unset options instead of
returning null as documented — reached through the same path.

Closes #63

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

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.53%. Comparing base (87fa3b3) to head (dd5aeb1).
⚠️ Report is 2 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master     #132      +/-   ##
============================================
+ Coverage     97.05%   97.53%   +0.47%     
- Complexity      309      326      +17     
============================================
  Files            25       25              
  Lines           986     1014      +28     
============================================
+ Hits            957      989      +32     
+ Misses           29       25       -4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

dakorpar and others added 2 commits August 14, 2026 21:03
Direct coverage for what the {formPrint} fix added, which until now was
only exercised incidentally through one Blueprint case:

- BootstrapRow's lookupPath()/getLabel()/isRequired(), including that the
  path a fake control reports matches what a real sibling reports
- getOption() returning null for an unset key without raising a warning
- the renderer drawing a plain Nette\Forms\Form, directly and via a clone
- a Bootstrap 5 blueprint, asserting the v5 wrapper rather than v4's

The plain-form, cloned-renderer and Bootstrap 5 scenarios are adapted from
f3l1x's PR #111, which independently fixed the same Blueprint incompatibility.

Also call BootstrapForm::isShowValidation() rather than reaching for the
magic property, as #111 does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reusing Nette's 'rendered' option to de-duplicate grouped controls under
{formPrint} had a cost that outweighed it: BaseControl::getControl() sets
that key on every fetch, so the renderer could no longer tell "I drew this"
apart from "somebody asked for the html". Assisted manual rendering --
renderControls() without a preceding render(), which attachForm() documents
-- then silently skipped any control whose html had been fetched.

RendererOptions::_RENDERED goes back to '_rendered', so its value is
unchanged from before this branch and nothing outside is affected. The
renderer instead remembers what it drew in an SplObjectStorage, which is
identity-based and therefore immune to Blueprint's dummy controls
forwarding getOption() to the control they wrap. Marking a control rendered
by hand still works, since isRendered() honours the option too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

{formPrint} macro not attached

1 participant