PDFlibPas's TPDFlibViewer control commits an in-place form field editor through the editor's own OnExit event, and that design choice hides a classic Delphi VCL trap: hiding, reparenting, or destroying a focused control from inside its own OnExit handler can fire OnExit a second time before the first call returns, sending the commit logic back into itself
The failure this produces resists a clean reproduction. A user tabs quickly through a run of text fields on a scanned application form, and every so often the viewer throws an access violation, or worse, keeps running while quietly writing the wrong value into a field two tabs back. Reproduce it on demand and the bug looks obvious in hindsight; chase it from a single customer's crash report and it looks like a ghost, because whether the second OnExit actually fires depends on window-handle and focus timing that shifts with field type, typing speed, and whatever else the message queue is doing at that instant
How TPDFlibViewer puts a real editor on top of a rendered page
TPDFlibViewer renders each PDF page to a bitmap and does not turn form fields into live VCL controls by default, so BeginEditFormField is the method that bridges the two worlds: called with a field index, it looks up the field's rectangle and converts it to client coordinates, then drops a real TEdit or TMemo on top of that rectangle for a text field, or a TComboBox in csDropDownList style for a choice field, complete with the field's current value already loaded. ISO 32000-2 §12.7 defines what a text or choice form field is inside a PDF, but nothing in that specification says how a Windows application should let someone type into one, and that gap is exactly what BeginEditFormField exists to fill. Both OnKeyDown and OnExit are wired to the same two viewer methods, InplaceEditorKeyDown and InplaceEditorExit, on every editor TPDFlibViewer creates, a pairing that has shipped unchanged since interactive form filling first landed in v3.220.0, and OnExit is where the trouble starts
Why does hiding the editor fire OnExit a second time?
TWinControl in the VCL treats a change to Visible or Parent on a focused control as a reason to move focus away from it immediately, and moving focus away from a control is exactly what fires that control's OnExit event, synchronously, before the property assignment that triggered it even returns. CommitInplaceEditor, the method PDFlibPas uses to close the in-place editor and write its value back to the form field, needs to do precisely those two things on the way out: set Editor.Visible to False and set Editor.Parent to nil so the control stops drawing on top of the page and stops receiving input. Do either of those while the editor still has focus, which it almost always does since the user just left it, and OnExit fires again in the middle of the very call that was supposed to be the last thing that editor's OnExit ever triggered
What goes wrong when CommitInplaceEditor reenters itself?
A naive commit method pays for this in one of two ways. Either it writes the field's value twice, once from the original call and once from the reentrant call that snuck in before the first one finished touching its own state, or it tries to free the editor control while a frame further down the call stack is still inside that same control's own event handler, which is undefined territory in the VCL and shows up as an access violation that can point at almost any line, not necessarily the one that actually caused it. Neither failure needs a large form to trigger; a two-field document is enough, provided the user leaves the second field fast enough for the OS to still be unwinding focus messages from the first
// Naive version: reads fine in review, fails only under real typing speed
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
CommitEditor; // still running inside FEditor's own OnExit
end;
procedure TMyPdfViewer.CommitEditor;
begin
if not Assigned(FEditor) then
Exit;
SaveFieldValue(FEditor.Text);
FEditor.Parent := nil; // focused control reparented here: OnExit
// fires again, re-entering this same method
FEditor.Free; // freed while a caller further down the
FEditor := nil; // stack is still inside its OnExit handler
end;
Nil the reference before you touch the control
The fix PDFlibPas ships is a single reordering: capture the editor in a local variable, clear the field that points to it, and only then start changing the control's properties. CommitInplaceEditor reads FInplaceEditor into a local Editor variable, sets FInplaceEditor to nil immediately, and only after that assigns Editor.Visible and Editor.Parent. A reentrant call triggered by either of those two assignments reads FInplaceEditor itself, finds it already nil, and exits on its very first line, before it can touch Editor or write the field's value a second time
procedure TPDFlibViewer.CommitInplaceEditor(Save: Boolean);
var
Editor: TWinControl;
begin
Editor := FInplaceEditor;
if not Assigned(Editor) then
Exit; // a reentrant call lands here and stops
FInplaceEditor := nil; // detach before the control is touched at all
if Save then
SaveEditorValue(Editor); // safe: FInplaceEditor is already nil
Editor.Visible := False;
Editor.Parent := nil; // may fire OnExit again; the guard above
// turns that reentrant call into a no-op
ReapDeadEditor; // free whatever was parked last cycle
FDeadEditor := Editor; // park this one instead of freeing it here
end;
SaveEditorValue in that listing stands in for the real branch, which checks whether Editor is a TComboBox, a TMemo, or a TEdit and reads its value accordingly, since PDFlibPas creates a different control depending on whether the field is a text field or a choice field. The guard does not care which branch runs, only that FInplaceEditor is nil before anything capable of triggering OnExit executes, which is the one ordering constraint that makes the rest of the method safe to write in whatever style is otherwise natural
Never free a control from inside its own event
TPDFlibViewer.CommitInplaceEditor never calls Editor.Free directly, and that is deliberate: freeing a control is unsafe while a stack frame belonging to that same control's own event dispatch might still be unwinding above the call that frees it, reentrant OnExit or not. PDFlibPas instead hands the detached editor to a single-slot parking spot, FDeadEditor, freeing whatever was sitting there from the previous edit cycle through a small helper, ReapDeadEditor, called at the start of the next BeginEditFormField and once more from CloseDocument; every editor the viewer creates is also owned by the viewer itself, TEdit.Create(Self) rather than TEdit.Create(nil), so even a control still parked in FDeadEditor when the viewer is destroyed gets swept up by ordinary VCL component ownership instead of leaking
procedure TPDFlibViewer.ReapDeadEditor;
begin
if Assigned(FDeadEditor) then
begin
FDeadEditor.Free; // safe now: this control's own OnExit
FDeadEditor := nil; // finished at least one edit cycle ago
end;
end;
function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
Edit: TEdit;
begin
Result := 0;
CommitInplaceEditor(True); // flush whatever editor is still open
// ... field lookup and rectangle conversion omitted ...
ReapDeadEditor; // now safe to free last cycle's parked editor
Edit := TEdit.Create(Self);
Edit.Parent := Self;
Edit.OnExit := InplaceEditorExit;
FInplaceEditor := Edit;
FInplaceEditor.SetFocus;
Result := 1;
end;
Why does this show up hardest during fast Tab navigation?
FocusNextFormField, the method PDFlibPas added in v3.226.0 to drive Tab and Shift+Tab navigation across a form, calls BeginEditFormField for the next eligible field on every single hop, and BeginEditFormField opens by calling CommitInplaceEditor(True) to flush whatever editor the previous field left open. That means every Tab press a user makes while filling out a multi-field form runs the exact detach-then-touch sequence described above once, which is precisely the code path most likely to still have a control genuinely focused at the moment Visible and Parent change, because Tab is the one interaction almost guaranteed to leave the outgoing editor holding focus right up until the new one asks for it
None of this makes the bug reliable to demonstrate, and that is worth saying plainly rather than glossing over. Whether a given Visible or Parent assignment actually forces a synchronous OnExit depends on focus and window-handle state that a debugger changes just by being attached, that an unrelated repaint or timer can perturb, and that behaves differently depending on which of TEdit, TMemo, or TComboBox happens to be the control in play. A guard that only sometimes gets exercised is the reason this kind of defect survives code review and manual testing alike, and it is also the reason the fix has to be correct by construction, niling the reference before anything else happens, rather than correct by whatever behaviour a handful of manual test passes happened to observe
The general shape of this fix travels well beyond one viewer control. Any custom editing surface built by overlaying a live VCL control on rendered content, not just a PDF form field, inherits the same hazard the moment its close-and-commit logic can be triggered by both an explicit user action and an implicit focus change, and the same two-part answer applies: clear the reference that identifies the active control before doing anything that might trigger its own exit event, and never call Free from a code path that could still be running underneath that control's own event dispatch. TPDFlibViewer's broader form-filling and rendering surface, including how it decides which control type to show for which field, is covered in the overview of building an interactive PDF viewer control in Delphi VCL with PDFlibPas, and the page bitmap cache that SetFormFieldValueAndRefresh has to invalidate on every committed edit is covered separately in the piece on the viewer's per-monitor DPI disk page cache
In-place form field editing, Tab-driven field navigation, and the reentrancy-safe commit path behind both are part of the interactive viewer control shipped with PDFlibPas, the PDF library for Delphi and C++Builder, alongside the rest of its page rendering, annotation, and form-field API surface