A refined version of this code is available at http://code.google.com/p/jolira-tools/wiki/stateless.

Wicket currently (1.4.6) supports a StatelessForm, but no stateless Ajax components. Because I really, really need stateless Ajax behavior for a mobile application for a very, very large online retailer, I have written the following class:

import static org.apache.wicket.protocol.http.request.WebRequestCodingStrategy.BOOKMARKABLE_PAGE_PARAMETER_NAME;

import org.apache.wicket.Page;
import org.apache.wicket.ajax.AjaxEventBehavior;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.IAjaxCallDecorator;
import org.apache.wicket.ajax.calldecorator.CancelEventIfNoAjaxDecorator;
import org.apache.wicket.ajax.markup.html.AjaxFallbackLink;
import org.apache.wicket.ajax.markup.html.IAjaxLink;
import org.apache.wicket.markup.ComponentTag;
import org.apache.wicket.markup.html.link.Link;
import org.apache.wicket.model.IModel;

/**
 * Just like {@link AjaxFallbackLink}, but stateless.
 * 
 */
public abstract class StatelessAjaxFallbackLink<T> extends Link<T> implements
        IAjaxLink {
    private static final long serialVersionUID = -133600842398684777L;

    public StatelessAjaxFallbackLink(final String id) {
        this(id, null);
    }

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

        add(new AjaxEventBehavior("onclick") {
            private static final long serialVersionUID = 1L;

            @Override
            protected IAjaxCallDecorator getAjaxCallDecorator() {
                return new CancelEventIfNoAjaxDecorator(
                        StatelessAjaxFallbackLink.this.getAjaxCallDecorator());
            }

            @Override
            public final CharSequence getCallbackUrl(
                    final boolean onlyTargetActivePage) {
                final CharSequence callbackUrl = super.getCallbackUrl(false);

                return decorateCallbackUrl(callbackUrl);
            }

            @Override
            @SuppressWarnings("synthetic-access")
            protected void onComponentTag(final ComponentTag tag) {
                // only render handler if link is enabled
                if (isLinkEnabled()) {
                    super.onComponentTag(tag);
                }
            }

            @Override
            protected void onEvent(final AjaxRequestTarget target) {
                onClick(target);
            }
        });
    }

    protected final CharSequence decorateCallbackUrl(
            final CharSequence callbackUrl) {
        final Page page = getPage();
        final Class<? extends Page> pageCls = page.getClass();
        final String pageClsName = pageCls.getName();
        final StringBuilder buf = new StringBuilder();

        buf.append(callbackUrl);
        buf.append('&');
        buf.append(BOOKMARKABLE_PAGE_PARAMETER_NAME);
        buf.append("=");
        buf.append(PATH_SEPARATOR);
        buf.append(pageClsName);

        return buf; 
    }

    /**
     * 
     * @return call decorator to use or null if none
     */
    protected IAjaxCallDecorator getAjaxCallDecorator() {
        return null;
    }

    /**
     * Hints that this component is stateless.
     * 
     * @return always {@literal true}
     * @see Link#getStatelessHint()
     */
    @Override
    protected boolean getStatelessHint() {
        return true;
    }

    /**
     * @see Link#onClick()
     */
    @Override
    public final void onClick() {
        onClick(null);
    }

    /**
     * Callback for the onClick event. If ajax failed and this event was
     * generated via a normal link the target argument will be null
     * 
     * @param target
     *            ajax target if this linked was invoked using ajax, null
     *            otherwise
     */
    public abstract void onClick(final AjaxRequestTarget target);
}

This code is almost identical to what can be found in the AjaxFallbackLink. The only main difference is that it adds another parameter to the generated URL, which identifies the class the page targets using the standard "wicket:bookmarkablePage" parameter.

This alone does not make the solution work yet as the default request process is confused about the newly generated request target. In order to rectify this situation, I have added the following code to my application class:

    // ... lots of irrelevant code omitted 
    /**
     * Install a new request processor to handle requests generated by any
     * {@link StatelessAjaxFallbackLink}.
     * 
     * @see WebApplication#newRequestCycleProcessor()
     */
    @Override
    protected IRequestCycleProcessor newRequestCycleProcessor() {
        final IRequestCycleProcessor processor = super
                .newRequestCycleProcessor();

        return new IRequestCycleProcessor() {
            @Override
            public IRequestCodingStrategy getRequestCodingStrategy() {
                return processor.getRequestCodingStrategy();
            }

            @Override
            public void processEvents(final RequestCycle requestCycle) {
                processor.processEvents(requestCycle);
            }

            @Override
            public IRequestTarget resolve(final RequestCycle requestCycle,
                    final RequestParameters requestParameters) {
                return MobileApplication.this.resolve(processor, requestCycle,
                        requestParameters);
            }
        };
    }

    protected IRequestTarget resolve(final IRequestCycleProcessor processor,
            final RequestCycle cycle, final RequestParameters requestParameters) {
        final RequestListenerInterface iface = requestParameters.getInterface();
        final String behaviorId = requestParameters.getBehaviorId();
        final String pageName = requestParameters.getBookmarkablePageClass();

        if (iface == null || behaviorId == null || pageName == null) {
            return processor.resolve(cycle, requestParameters);
        }

        final Map<String, ?> _params = requestParameters.getParameters();
        final PageParameters params = new PageParameters(_params);
        final Session session = cycle.getSession();
        final Class<? extends Page> pageClass = getPageClass(session, pageName);
        final Page page = newPage(pageClass, cycle, params);
        final String componentPath = requestParameters.getComponentPath();
        final String pageRelativeComponentPath = Strings
                .afterFirstPathComponent(componentPath,
                        Component.PATH_SEPARATOR);
        final Component component = page.get(pageRelativeComponentPath);

        // See {@link
        // BookmarkableListenerInterfaceRequestTarget#processEvents(RequestCycle)}
        // We make have to try to look for the component twice, if we hit the
        // same condition.
        if (component == null) {
            throw new WicketRuntimeException(
                    "unable to find component with path "
                            + pageRelativeComponentPath
                            + " on stateless page "
                            + page
                            + " it could be that the component is inside a repeater make your component return false in getStatelessHint()");
        }

        final String interfaceName = requestParameters.getInterfaceName();
        final RequestListenerInterface listenerInterface = RequestListenerInterface
                .forName(interfaceName);

        if (listenerInterface == null) {
            throw new WicketRuntimeException(
                    "unable to find listener interface " + interfaceName);
        }

        return new BehaviorRequestTarget(page, component, listenerInterface,
                requestParameters);
    }
    // lots of irrelevant code omitted ....