Skip to content

Let an application component register models beside its default model - #1605

Open
reiern70 wants to merge 4 commits into
masterfrom
reiern70/support-multiple-models
Open

reiern70 wants to merge 4 commits into
masterfrom
reiern70/support-multiple-models

Conversation

@reiern70

Copy link
Copy Markdown
Contributor

A component has a single model that the framework detaches at the end of each request. A component using further models has to detach them itself in onDetach(), detachModel() or detachModels(); when it forgets, a LoadableDetachableModel stays loaded and is serialized with the page.

Component can now track additional models next to the default model:

  • addAdditionalModel(M) registers a model and returns it, typed as given, so it can be assigned in the same statement: foo = addAdditionalModel(new FooModel()). Registering a model twice has no effect.
  • replaceAdditionalModel(IModel, M) detaches and unregisters the previous model and registers the given one, unless both are the same, and returns the given model; it is meant for setters.
  • removeAdditionalModel(M) detaches, unregisters and returns a model.
  • getModels() returns the default model, if any, followed by the additional models, without triggering model inheritance.
  • detachModels() detaches the additional models, including the model inside a wrapper, as detachModel() does for the default model.
  • Component(String, IModel, IModel...) registers the given additional models; MarkupContainer, WebMarkupContainer, WebComponent, Panel and GenericPanel get the same constructor. The existing (String, IModel) constructors stay; a (String, IModel...) overload was avoided because it would make new X("id", null) ambiguous.

Additional models are registered as given and not wrapped, so the methods can return the model itself; a component using an IComponentAssignedModel registers wrap(model). They are tracked without an index, so a subclass does not need to know which models its superclasses use.

The models are kept as component meta data, which costs a component roughly 50 to 80 bytes more than detaching the same models by hand, whatever their number: a meta data entry, the array holding the models, and the state holder a component needs once it has more than one kind of state. That is a good trade for a component used a few dozen times on a page and a bad one for a component rendered in the thousands, so the components shipped with Wicket are left as they are and keep detaching their models themselves. The javadoc of addAdditionalModel and the user guide say so, with the numbers.

Detaching an IWrapModel whose wrapped model is null no longer throws.

The user guide section on components with more than one model describes the new methods.

@reiern70
reiern70 requested a review from papegaaij September 17, 2026 21:20
@codecov-commenter

codecov-commenter commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.09859% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.92%. Comparing base (03dc5e1) to head (8af129a).

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #1605      +/-   ##
============================================
+ Coverage     61.89%   61.92%   +0.02%     
- Complexity    11244    11270      +26     
============================================
  Files          1247     1247              
  Lines         48367    48435      +68     
  Branches       6788     6801      +13     
============================================
+ Hits          29937    29993      +56     
- Misses        15725    15731       +6     
- Partials       2705     2711       +6     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mattrpav

Copy link
Copy Markdown
Contributor

@reiern70 can you provide example scenario(s) where you find a need to have multiple models for a component?

@mattrpav
mattrpav self-requested a review September 18, 2026 15:19
@reiern70

Copy link
Copy Markdown
Contributor Author

@reiern70 can you provide example scenario(s) where you find a need to have multiple models for a component?

Components with more than one model

Wicket 11 lets a component register models beside its default model. The framework then
detaches them for you, at the end of every request, together with the default model.

The short version: you no longer have to care about detaching. The component detaches itself.

Every section below is the same code twice — before, as you write it today, and after, with
the new API.


1. A component with two models

Before

A component has exactly one default model — the one passed to super(id, model) — and the
framework detaches that one. Every other model kept in a field is the developer's problem:

public class CustomerCardPanel extends GenericPanel<Customer>
{
	private final IModel<List<Order>> ordersModel;
	private final IModel<Address> addressModel;

	public CustomerCardPanel(String id, IModel<Customer> customer,
		IModel<List<Order>> orders, IModel<Address> address)
	{
		super(id, customer);

		// Just fields. Nothing detaches these two.
		this.ordersModel = orders;
		this.addressModel = address;
	}

	@Override
	protected void onDetach()
	{
		// Forget one of these lines and the mistake is silent: the
		// LoadableDetachableModel stays loaded, its entities are dragged into the
		// page store, and the page grows for as long as it lives in the session.
		ordersModel.detach();
		addressModel.detach();

		super.onDetach();
	}
}

Nothing tells you when you get this wrong. The page still renders. It is only bigger, staler and
slower than it should be — and the model still holds last request's data.

After

addAdditionalModel(model) registers a model and returns it, typed as it was given, so the
registration happens in the same statement as the assignment. The field stays final, and
onDetach() disappears:

public class CustomerCardPanel extends GenericPanel<Customer>
{
	private final IModel<List<Order>> ordersModel;
	private final IModel<Address> addressModel;

	public CustomerCardPanel(String id, IModel<Customer> customer,
		IModel<List<Order>> orders, IModel<Address> address)
	{
		super(id, customer);

		// Registered, not just assigned: both are detached with the default model.
		this.ordersModel = addAdditionalModel(orders);
		this.addressModel = addAdditionalModel(address);
	}

	// No onDetach(). There is nothing left to detach by hand.
}

addAdditionalModel accepts null (it registers nothing and returns null), and registering the
same model twice has no effect — so a component that is unsure whether a model was already
registered can simply register it again.


2. Models the component creates itself

The most common case for several models: the component builds its own loadable models from its
default model.

Before

public class CustomerCardPanel extends GenericPanel<Customer>
{
	private final IModel<List<Order>> ordersModel;
	private final IModel<Integer> unpaidCountModel;

	public CustomerCardPanel(String id, IModel<Customer> customer, OrderService orders)
	{
		super(id, customer);

		this.ordersModel = LoadableDetachableModel.of(
			() -> orders.findByCustomer(getModelObject()));

		this.unpaidCountModel = LoadableDetachableModel.of(
			() -> (int)ordersModel.getObject().stream().filter(Order::isUnpaid).count());
	}

	@Override
	protected void onDetach()
	{
		// Two models created here, two lines to keep in sync with the fields above.
		// Add a third model next year and this method has to be remembered.
		ordersModel.detach();
		unpaidCountModel.detach();

		super.onDetach();
	}
}

After

public class CustomerCardPanel extends GenericPanel<Customer>
{
	private final IModel<List<Order>> ordersModel;
	private final IModel<Integer> unpaidCountModel;

	public CustomerCardPanel(String id, IModel<Customer> customer, OrderService orders)
	{
		super(id, customer);

		// A model per piece of derived state, each loaded at most once per request
		// and dropped again when the request ends.
		this.ordersModel = addAdditionalModel(
			LoadableDetachableModel.of(() -> orders.findByCustomer(getModelObject())));

		this.unpaidCountModel = addAdditionalModel(
			LoadableDetachableModel.of(() -> (int)ordersModel.getObject().stream()
				.filter(Order::isUnpaid)
				.count()));
	}
}

Note what did not have to be written: no onDetach(), no null checks, no bookkeeping about
which of the two models is loaded. unpaidCountModel reads through ordersModel, so the orders
are fetched once and both models drop their reference at the end of the request.


3. Models the component does not need a field for

When a model is only handed to a child and never touched again, there is nothing to assign it to.

Before

public class InvoiceHeaderPanel extends GenericPanel<Invoice>
{
	// Fields that exist for one reason only: so that onDetach() can reach them.
	private final IModel<Company> issuerModel;
	private final IModel<Customer> recipientModel;

	public InvoiceHeaderPanel(String id, IModel<Invoice> invoice,
		IModel<Company> issuer, IModel<Customer> recipient)
	{
		super(id, invoice);

		this.issuerModel = issuer;
		this.recipientModel = recipient;

		add(new CompanyPanel("issuer", issuer));
		add(new CustomerPanel("recipient", recipient));
	}

	@Override
	protected void onDetach()
	{
		issuerModel.detach();
		recipientModel.detach();

		super.onDetach();
	}
}

After

Pass them straight to the constructor: Component(String, IModel, IModel...) registers every
additional model it is given. MarkupContainer, WebComponent, WebMarkupContainer, Panel and
GenericPanel all have the same constructor.

public class InvoiceHeaderPanel extends GenericPanel<Invoice>
{
	public InvoiceHeaderPanel(String id, IModel<Invoice> invoice,
		IModel<Company> issuer, IModel<Customer> recipient)
	{
		// invoice is the default model; issuer and recipient are registered as
		// additional models and detached along with it.
		super(id, invoice, issuer, recipient);

		add(new CompanyPanel("issuer", issuer));
		add(new CustomerPanel("recipient", recipient));
	}
}

Two fields, two detach calls and the whole onDetach() are gone. The child components hold these
models too, but a child only detaches its own default model — which is the very same instance,
so detaching it twice is harmless, and registering it here is what guarantees it happens even when
the children are not rendered.


4. Replacing a model in a setter

A model field with a setter has to detach the model it drops — otherwise the old model is simply
leaked, and the new one may never be detached at all.

Before

public void setAddressModel(IModel<Address> addressModel)
{
	// Easy to get wrong in both directions: forget the detach and the old model
	// leaks; detach unconditionally and you detach a model that is still in use
	// when the same instance is set twice.
	if (this.addressModel != null && this.addressModel != addressModel)
	{
		this.addressModel.detach();
	}
	this.addressModel = addressModel;
}

After

replaceAdditionalModel(previous, model) does both halves: it detaches and unregisters
previous, registers model, and returns model.

public void setAddressModel(IModel<Address> addressModel)
{
	// Detaches and unregisters the previous model, registers the new one.
	// Passing the same model twice is a no-op, so nothing is detached needlessly.
	this.addressModel = replaceAdditionalModel(this.addressModel, addressModel);
}

public void clearAddressModel()
{
	// removeAdditionalModel detaches and unregisters, and returns what it was given.
	removeAdditionalModel(this.addressModel);
	this.addressModel = null;
}

5. Inheritance: a subclass does not need to know

Before

Both classes have an onDetach(), and the subclass has to remember super.onDetach() — miss it
and the superclass' models silently stop being detached:

public class BaseCardPanel<T> extends GenericPanel<T>
{
	protected final IModel<Branding> brandingModel;

	public BaseCardPanel(String id, IModel<T> model)
	{
		super(id, model);

		this.brandingModel = LoadableDetachableModel.of(BrandingService::current);
	}

	@Override
	protected void onDetach()
	{
		brandingModel.detach();

		super.onDetach();
	}
}

public class CustomerCardPanel extends BaseCardPanel<Customer>
{
	private final IModel<List<Order>> ordersModel;

	// ... constructor ...

	@Override
	protected void onDetach()
	{
		ordersModel.detach();

		// Forget this line and brandingModel is never detached again.
		super.onDetach();
	}
}

After

Additional models are tracked as a set, not by index. A subclass registers its own models without
knowing, or caring, how many its superclass already registered — and nothing breaks when the
superclass later adds one:

public class BaseCardPanel<T> extends GenericPanel<T>
{
	protected final IModel<Branding> brandingModel;

	public BaseCardPanel(String id, IModel<T> model)
	{
		super(id, model);

		this.brandingModel = addAdditionalModel(
			LoadableDetachableModel.of(BrandingService::current));
	}
}

public class CustomerCardPanel extends BaseCardPanel<Customer>
{
	private final IModel<List<Order>> ordersModel;

	public CustomerCardPanel(String id, IModel<Customer> customer, OrderService orders)
	{
		super(id, customer);

		// Registered next to the superclass' model. No index, no super call to remember,
		// no coordination between the two classes.
		this.ordersModel = addAdditionalModel(
			LoadableDetachableModel.of(() -> orders.findByCustomer(getModelObject())));
	}
}

6. IComponentAssignedModel: wrap it yourself

The default model is wrapped for the component behind your back — that is how
CompoundPropertyModel learns the component it belongs to. An additional model is registered
exactly as given, so that the method can hand it back to you unchanged.

Before

// The model was never bound to this component, so the bundle is resolved
// against whatever component happens to render the string.
this.titleModel = new ResourceModel("customer.card.title");

After

If the model is an IComponentAssignedModelResourceModel, StringResourceModel,
CompoundPropertyModel, PropertyModel on a compound parent — wrap it yourself:

// wrap() binds the ResourceModel to this component, so the bundle is resolved
// relative to this panel. The wrapper is what gets registered and detached,
// and the model inside it is detached with it.
this.titleModel = addAdditionalModel(wrap(new ResourceModel("customer.card.title")));

For a plain model — Model, LoadableDetachableModel, a lambda model — there is nothing to wrap
and nothing to think about.


7. Seeing what a component holds

Before

There was no way to ask. The additional models were private fields of the component, so a test
could only assert on their effects.

After

getModels() returns the default model first (when there is one) followed by the additional
models, in registration order. It does not trigger model inheritance, so calling it never
creates a model as a side effect — which makes it safe for debugging, tests and tooling:

// e.g. in a test: assert that the panel really registered everything it should
Collection<IModel<?>> models = panel.getModels();
assertEquals(3, models.size());

8. When not to use this

Registering is a convenience with a price. The models are kept as component meta data, which
costs roughly 50 to 80 bytes per component more than detaching the same models by hand —
whatever the number of models, since it pays for the meta data entry, the array and the
component's state holder.

  • A panel used a few dozen times on a page: register, and never think about detaching again.
  • A component rendered in the thousands — a cell inside a large DataTable, an item of a long
    ListView: those bytes are multiplied by the instance count. Keep the before version and
    detach by hand in onDetach().

This is why the components shipped with Wicket keep detaching their own models: they have to
assume the second case. Application components are almost always the first.


API summary

Method What it does
addAdditionalModel(M model) Registers model, returns it typed as given. null and double registration are no-ops.
replaceAdditionalModel(IModel<?> previous, M model) Detaches and unregisters previous, registers and returns model. Same model twice is a no-op.
removeAdditionalModel(M model) Detaches, unregisters and returns model.
getModels() Default model (if any) then the additional models, in registration order. Never initializes a model.
Component(String id, IModel<?> model, IModel<?>... additionalModels) Default model plus additional models to register.

addAdditionalModel, replaceAdditionalModel and removeAdditionalModel are protected final
a component manages its own models. getModels() is public final.

All of it is @since 11.0.0.


Running example

A working version of this lives in the component reference of wicket-examples:
MultipleModelsPage in org.apache.wicket.examples.compref, reachable from the reference index.

@reiern70
reiern70 force-pushed the reiern70/support-multiple-models branch from 659678e to d539aed Compare September 18, 2026 17:17
@jonathanlocke

Copy link
Copy Markdown
Contributor

It's been a long time since I looked at a PR for Wicket, but I've been getting back into things over the past couple years. I feel a bit "on the fence" on this PR. I can see it both ways. Arguably, you could say that a component should have one IModel and that cases where you have multiple models are usually a case where things could be decomposed better. For example, you could have a composite IModel that detaches its sub-models or in some cases you could simply break up the component into sub-panels. But there are cases where this PR addresses a real flaw, including one that's already in Wicket. For example, the multi-select list choice component quite legitimately has two unrelated models (the options model and the selection model). What we wound up doing 15-20 years ago with this is to have a component have one primary or "default" model (that's why it's getDefaultModelObject() instead of getModelObject() if I remember correctly) and then your component can have more models that you manage yourself. If this PR gets approved, it seems like the multi-select choice component is a poster child for the issue this PR addresses and that it would benefit by adopting this API. My first worry when I saw the PR title was negative because I didn't want the IModel field on Component to become a list, but I think this PR recognizes that this is an unusual case and using metadata seems one good solution to keep components slim while implementing support for detaching multiple models. Another one would be be creating a composite model that absorbs and manages the primary and additional models. That might be worth prototyping, actually to see if it is cleaner/simpler. I can't yet see a reason to vote "no" on this, but it would be good to do some thinking about what this does to Wicket overall in terms of how people code in it. It's a pretty deep change, so it would be good to get a lot of eyes on this, but I can't see a good reason to say "no" at first glance. If approved, I'd like to see any core components in wicket that already have multiple models adopt the API in a follow-on PR.

@reiern70

Copy link
Copy Markdown
Contributor Author

It's been a long time since I looked at a PR for Wicket, but I've been getting back into things over the past couple years. I feel a bit "on the fence" on this PR. I can see it both ways. Arguably, you could say that a component should have one IModel and that cases where you have multiple models are usually a case where things could be decomposed better. For example, you could have a composite IModel that detaches its sub-models or in some cases you could simply break up the component into sub-panels. But there are cases where this PR addresses a real flaw, including one that's already in Wicket. For example, the multi-select list choice component quite legitimately has two unrelated models (the options model and the selection model). What we wound up doing 15-20 years ago with this is to have a component have one primary or "default" model (that's why it's getDefaultModelObject() instead of getModelObject() if I remember correctly) and then your component can have more models that you manage yourself. If this PR gets approved, it seems like the multi-select choice component is a poster child for the issue this PR addresses and that it would benefit by adopting this API. My first worry when I saw the PR title was negative because I didn't want the IModel field on Component to become a list, but I think this PR recognizes that this is an unusual case and using metadata seems one good solution to keep components slim while implementing support for detaching multiple models. Another one would be be creating a composite model that absorbs and manages the primary and additional models. That might be worth prototyping, actually to see if it is cleaner/simpler. I can't yet see a reason to vote "no" on this, but it would be good to do some thinking about what this does to Wicket overall in terms of how people code in it. It's a pretty deep change, so it would be good to get a lot of eyes on this, but I can't see a good reason to say "no" at first glance. If approved, I'd like to see any core components in wicket that already have multiple models adopt the API in a follow-on PR.

Hi...

thanks for your feedback and thanks for Wicket! and welcome back!

  1. How do you plan to be implemented this getObject from a compound model? Then users will be forced to allways roll a weird object where all is included? Let's take a search panel as example. Such a component mayb have a mix of a) some Model with serializable data (e.g. user input in a component usedto hold parameters in a sreach) b) a fetcher LDM that provides a List of matches c) for each row another LDM plus some other info (e.g. is column selected). If we do what you mention at least a and b would be needed to unify in one model... Correct?
  2. Why I wanted this feature. We have several developers on our team. Some are more proficient in Wicket than others. Some of them need to be constantly reminded of Wicket lifecycle and get rid of what is not needed.
  3. I didn't use this for standard compoenets as it is more costly than the default. Thus, I conciusly left those untouched.

A component has a single model that the framework detaches at the end of
each request. A component using further models has to detach them itself in
onDetach(), detachModel() or detachModels(); when it forgets, a
LoadableDetachableModel stays loaded and is serialized with the page.

Component can now track additional models next to the default model:

- addAdditionalModel(M) registers a model and returns it, typed as given, so
  it can be assigned in the same statement:
  foo = addAdditionalModel(new FooModel()). Registering a model twice has no
  effect.
- replaceAdditionalModel(IModel, M) detaches and unregisters the previous
  model and registers the given one, unless both are the same, and returns
  the given model; it is meant for setters.
- removeAdditionalModel(M) detaches, unregisters and returns a model.
- getModels() returns the default model, if any, followed by the additional
  models, without triggering model inheritance.
- detachModels() detaches the additional models, including the model inside
  a wrapper, as detachModel() does for the default model.
- Component(String, IModel, IModel...) registers the given additional
  models; MarkupContainer, WebMarkupContainer, WebComponent, Panel and
  GenericPanel get the same constructor. The existing (String, IModel)
  constructors stay; a (String, IModel...) overload was avoided because it
  would make new X("id", null) ambiguous.

Additional models are registered as given and not wrapped, so the methods
can return the model itself; a component using an IComponentAssignedModel
registers wrap(model). They are tracked without an index, so a subclass does
not need to know which models its superclasses use.

The models are kept as component meta data, which costs a component roughly
50 to 80 bytes more than detaching the same models by hand, whatever their
number: a meta data entry, the array holding the models, and the state
holder a component needs once it has more than one kind of state. That is a
good trade for a component used a few dozen times on a page and a bad one
for a component rendered in the thousands, so the components shipped with
Wicket are left as they are and keep detaching their models themselves. The
javadoc of addAdditionalModel and the user guide say so, with the numbers.

Detaching an IWrapModel whose wrapped model is null no longer throws.

The user guide section on components with more than one model describes the
new methods.

The guide section on components with more than one model now shows each case
before and after: a component with two models, models the component builds
itself, models it keeps no reference to, a setter, a subclass registering
beside its superclass, a model that has to be wrapped, and getModels(). The
passage telling the reader to detach by hand is kept, because that is still
the right answer for a component rendered in the thousands.

wicket-examples gains a component reference page, MultipleModelsPage, with a
CustomerCardPanel that uses four models and overrides no onDetach at all. It
lists its own models through getModels(), counts how often the orders model
is loaded so that one load per request is visible in the page, and replaces
that model through a setter, which exercises replaceAdditionalModel. Its
texts come from a resource bundle, through wicket:message in the markup and
StringResourceModel in the code, the order status through a key built from
the model.
wicket-benchmarks, wicket-coverage, wicket-extensions-tester and
wicket-migration are modules of the reactor but appeared neither in the
directory tree nor in the list of projects, so a reader of the README got an
incomplete picture of the distribution. They are now in both, with a line
each saying what they are for.

The tree also spelled wicket-objectsizeof-agent with a doubled s, which is
not the name of the module.
The javadoc of addAdditionalModel and the user guide put the price of letting
Component detach a component's extra models at roughly 50 to 80 bytes per
component, a figure no benchmark in the tree could produce: ComponentFootprint
measures state shapes built by decorating a plain WebMarkupContainer, and the
approach it has to be compared against is a subclass with model fields and a
hand written onDetach, which a Shape cannot express. Trait.METADATA also stores
a String under a MetaDataKey rather than the IModel[] the feature creates, so
MODEL_METADATA was only ever a proxy for it.

AdditionalModelsShapes holds the three ways a component can carry the same
extra models: in fields detached by hand, in fields registered with
addAdditionalModel, and registered through the constructor with no fields at
all. Every variant carries the same default model and the same extra models, so
a difference between two of them is the bookkeeping alone.

AdditionalModelsFootprint reports retained heap and serialized bytes for one,
two and three extra models, against the same component carrying only a default
model. AdditionalModelsBenchmark reports time and allocation for constructing
such a component and for the whole construct-and-detach cycle, which is where
registering does its extra work.

run-model-benchmarks.sh runs all of it. It copies the dependencies into the
module rather than pointing the classpath at ~/.m2, and puts the target/classes
directories first, so a run measures the working copy and not whatever was last
installed.

The measurements confirm the documented figure and sharpen it: 72 to 80 bytes
of retained heap per component, 56 to 64 with compact object headers, and near
flat in the number of models because only the IModel[] grows. Serialized size
is not flat, rising about 5 bytes per model, and construction is the cost the
prose does not mention at all: 80% slower with three extra models, 128 bytes
more allocated per component.
The benchmarks report numbers; nothing in the tree said what they came out as,
so the figure quoted in the javadoc and the user guide could not be checked
without re-running them.

MODEL_BENCH_MARK.md holds the results of one run: retained heap and serialized
bytes per component for one, two and three extra models, with and without
compact object headers, the JOL breakdown of where the bytes go, and time and
allocation for construction and for the construct-and-detach cycle. It names
the JVM, the heap and the JMH settings it was produced with, so a later run can
be compared against it or dismissed as measuring something else.

The conclusions it draws: the documented 50 to 80 bytes is right and slightly
conservative, the cost is near flat in the number of models for heap but not
for serialized size, and construction is 80% slower with three extra models,
which is the number that decides whether a component rendered in the thousands
can afford the convenience.
@reiern70
reiern70 force-pushed the reiern70/support-multiple-models branch from d539aed to 8af129a Compare September 18, 2026 20:28
Comment thread MODEL_BENCH_MARK.md

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@papegaaij This document contains the numbers of the new benchmarks

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.

4 participants