Remote Layer navigation
Remote Layer navigation is a stable, optional HTML-first module above Layer core. It is published as markup-refine-lib/layer-navigation rather than folded into the default behaviors bundle. The dependency direction stays one-way: remote navigation may use Layers; Layers never know about URLs, fetch, forms, redirects, or backend protocols.
Explicit orchestration
import { layerNavigation } from "markup-refine-lib/layer-navigation";
type AuthorChoice = { id: string; label: string };
const outcome = await layerNavigation.ask<AuthorChoice>({
url: createAuthorLink.href,
trigger: createAuthorLink,
});
if (outcome.status === "accepted") {
authorId.value = outcome.value.id;
authorLabel.value = outcome.value.label;
}ask() fetches the standalone URL first, extracts its declared fragment, creates a temporary Layer surface, and resolves with the sameLayerOutcome contract as local Layer interactions. The returned value is an opaque interaction result chosen by the application, not a Markup Refine entity model.
Lower-capability fallback
Semantic fallback remains the source
<a href="/authors/new" data-mr-layer-navigation>
Quick-create author
</a><!doctype html>
<html lang="en">
<head><title>Create author</title></head>
<body>
<main data-mr-layer-navigation-fragment>
<h1>Create author</h1>
<form action="/authors" method="post" data-mr-layer-navigation>
<input type="hidden" name="csrf_token" value="...server token...">
<label>Name <input name="name" required></label>
<button>Save</button>
</form>
<a href="/books/new" data-mr-layer-navigation-dismiss>Cancel</a>
</main>
</body>
</html>The href and action are real navigation targets. If JavaScript is unavailable, the browser follows/submits them normally. Every URL enhanced into a Layer must therefore remain useful as a complete standalone page. Thedata-mr-layer-navigation attribute only identifies links/forms that may be intercepted; it does not encode workflow logic.
Optional declarative link enhancement
import { initLayerNavigation } from "markup-refine-lib/layer-navigation";
// Optional. This is NOT included in markup-refine-lib/behaviors.
initLayerNavigation(document);This initializer only intercepts unmodified primary-button activation of opted-in links. New tabs/windows, downloads, unsupported schemes, and cross-origin destinations retain native browser behavior; enhanced remote Layers are same-origin only. If the initial enhanced fetch fails, the initializer reports mr:layer-navigation:error and falls back to the link's normal navigation target.
Public JavaScript API
import {
initLayerNavigation,
layerNavigation,
LayerNavigationHttpError,
LayerNavigationProtocolError,
} from "markup-refine-lib/layer-navigation";
initLayerNavigation(root?: ParentNode);
layerNavigation.initialize(root?: ParentNode);
layerNavigation.configure(options);
layerNavigation.ask<T, R>({
url,
trigger,
parent?,
signal?,
fragmentSelector?,
resultSelector?,
validationStatuses?,
redirect?,
credentials?,
requestHeaders?,
parseResult?,
onRender?,
});LayerNavigationHttpError represents a non-success HTTP response that is not handled as validation, while LayerNavigationProtocolError represents malformed or missing HTML protocol markers. The module remains opt-in and is not initialized by the default behavior bundle.
Public TypeScript types
| Type | Purpose |
|---|---|
LayerNavigation | Public service shape implemented by layerNavigation. |
LayerNavigationAskOptions | Per-interaction URL, trigger, optional parent/signal, plus per-call configuration overrides. |
LayerNavigationOptions | Stable configuration for fragment/result selectors, validation statuses, redirect/credentials policy, headers, result parsing, and post-render hooks. |
LayerNavigationRedirectMode | "error" | "follow". |
LayerNavigationRequestContext | Context passed to dynamic requestHeaders: URL, method, trigger, form, and submitter. |
LayerNavigationResultContext | Context passed to parseResult: response, parsed document, and result marker. |
LayerNavigationRenderContext | Context passed to onRender: response, fragment, surface, and validation flag. |
LayerNavigationErrorDetail | Detail for mr:layer-navigation:error: error, URL, optional response, and optional surface. |
LayerNavigationResultDetail<T, R> | Detail for mr:layer-navigation:result: Layer outcome plus URL. |
import type {
LayerNavigation,
LayerNavigationAskOptions,
LayerNavigationErrorDetail,
LayerNavigationOptions,
LayerNavigationRedirectMode,
LayerNavigationRenderContext,
LayerNavigationRequestContext,
LayerNavigationResultContext,
LayerNavigationResultDetail,
} from "markup-refine-lib/layer-navigation";Navigation lifecycle events
Declaratively enhanced links emit bubbling events on the originating link.mr:layer-navigation:result is emitted when the interaction settles and carriesLayerNavigationResultDetail. mr:layer-navigation:error carriesLayerNavigationErrorDetail when an enhanced request/protocol step fails. Programmatic layerNavigation.ask() returns/rejects directly; it does not require listening for these declarative-link events.
link.addEventListener("mr:layer-navigation:result", (event) => {
console.log(event.detail.outcome, event.detail.url);
});
link.addEventListener("mr:layer-navigation:error", (event) => {
console.error(event.detail.error, event.detail.url);
});Fragment contract
By default, fetched HTML must contain exactly the application region intended for the transient surface as [data-mr-layer-navigation-fragment]. The full response may include the site's normal layout; the module extracts only that region. A custom selector can be configured globally or per ask() call.
Remote fragments are treated as trusted application HTML, not arbitrary third-party HTML. The built-in import guard removes <script> elements, inlineon* handlers, and javascript: navigation attributes before insertion, but it is deliberately not advertised as a complete sanitizer. Enhanced requests are always same-origin; there is no public cross-origin override.
Loading, cancellation, and replacement
- The trigger receives
aria-busy="true"while the initial request is pending. - An open remote surface receives
aria-busy="true"anddata-mr-layer-navigation-state="loading"during in-Layer navigation. - Starting another remote request aborts the previous one; closing the Layer also aborts its active request.
- An optional caller
AbortSignalcancels the request and closes the remote Layer. - Aborted fetches are control flow, not HTTP errors, and do not render an error UI.
Validation responses
HTTP/1.1 422 Unprocessable Content
Content-Type: text/html
<!doctype html>
<html lang="en">
<head><title>Create author</title></head>
<body>
<main data-mr-layer-navigation-fragment>
<h1>Create author</h1>
<p role="alert">Please correct the highlighted field.</p>
<form action="/authors" method="post" data-mr-layer-navigation>
<input type="hidden" name="csrf_token" value="...server token...">
<label>
Name
<input name="name" value="" required aria-invalid="true">
</label>
<button>Save</button>
</form>
</main>
</body>
</html>Statuses in validationStatuses (400 and 422 by default) are allowed to return a replacement fragment. The Layer stays open, the server-rendered form/errors replace the previous fragment, and focus moves to autofocus, anaria-invalid control, or the first natively invalid control when present. Other non-success statuses emit mr:layer-navigation:error and keep the current surface intact so application code can decide how to report/recover.
Server result protocol
HTTP/1.1 200 OK
Content-Type: text/html
<!doctype html>
<html lang="en">
<head><title>Author created</title></head>
<body>
<p>Author created. This remains a meaningful standalone response.</p>
<script type="application/json"
data-mr-layer-navigation-result="accepted">
{"id":"author-42","label":"Ada Example"}
</script>
</body>
</html>A response may finish the interaction with one[data-mr-layer-navigation-result] marker whose value isaccepted or dismissed. Its text content is optional JSON and is passed through as the opaque accepted value/dismiss reason. Applications that do not want JSON payloads can replace the parser with parseResult; the endpoint is still an HTML endpoint, not a JSON-only API.
An accepted marker calls the active Layer's accept(value); a dismissed marker calls dismiss(reason). This means local and remote subinteractions share one result model without teaching Layer core anything about HTTP.
Redirect policy
Redirects default to redirect: "error". This avoids silently following an unexpected redirect after form data has already been sent. Applications may opt into"follow" for trusted endpoint chains; the final URL is then checked against the same origin policy before its response is used. Markup Refine does not mutate browser history for an in-Layer navigation.
CSRF
Markup Refine does not invent a framework-specific token name. Hidden CSRF controls are submitted naturally because form data is constructed from the semantic form, and same-origin credentials are used by default. Backends requiring a custom CSRF header can add it through requestHeaders. The server remains responsible for issuing/validating the token.
Multipart forms and file uploads
A file field does not force an enhanced nested workflow to escape to full-page navigation. Same-origin multipart/form-data forms inside a remote Layer are intercepted and submitted with the browser's native FormData(form, submitter)encoding, so selected files remain part of the same Layer interaction. Markup Refine deliberately does not set Content-Type for a FormData request; the browser must generate the multipart boundary.
<form
action="/authors"
method="post"
enctype="multipart/form-data"
data-mr-layer-navigation
>
<label>
Name
<input name="name" required>
</label>
<label>
Avatar
<input
id="author-avatar"
type="file"
name="avatar"
accept="image/*"
data-mr-preserve
>
</label>
<button type="submit">Create author</button>
</form>This is basic multipart submission, not an upload manager. There is no chunking, resumable upload protocol, background upload, progress API, or automatic mutation retry. Closing the Layer aborts its active request, and an ambiguous failed mutation stays in the current Layer so application/server UI can decide whether retrying is safe.
Preserving a selected file across validation
Replacing a validation fragment normally creates a new file input, and browsers do not allow scripts to reconstruct a user's local file selection. Adddata-mr-preserve plus a stable unique id to a stateful element that must survive a 400/422 fragment replacement. If the validation response contains the same preserved element, Markup Refine reuses the existing DOM node while applying the returned attributes. For a file input this keeps its browser-owned FileList.
HTTP/1.1 422 Unprocessable Content
Content-Type: text/html
<main data-mr-layer-navigation-fragment>
<p role="alert">Name is required.</p>
<form
action="/authors"
method="post"
enctype="multipart/form-data"
data-mr-layer-navigation
>
<label>
Name
<input name="name" required aria-invalid="true">
</label>
<label>
Avatar
<input
id="author-avatar"
type="file"
name="avatar"
accept="image/*"
data-mr-preserve
>
</label>
<button type="submit">Create author</button>
</form>
</main>Preservation is explicit. If the uploaded file itself is invalid and the server wants the user to select it again, omit data-mr-preserve from that field in the validation response. The replacement then proceeds normally and the file selection is cleared.
Lower-capability multipart fallback
The same form is still ordinary semantic HTML. Before enhancement commits, or when the remote module is unavailable, the browser submits the real multipart form to itsaction. Once an in-Layer form has been successfully enhanced, however, the mere presence of a file input no longer causes an unexpected full-page navigation that would discard parent Layer state. Unsupported encodings such as text/plain, non-self targets, cross-origin actions, and malformed GET/file combinations remain native fallbacks.
Configuration without an attribute DSL
layerNavigation.configure({
fragmentSelector: "[data-app-subinteraction]",
validationStatuses: [400, 422],
redirect: "error",
requestHeaders: ({ method }) => ({
"X-App-Interaction": method === "GET" ? "read" : "write",
}),
onRender: ({ fragment }) => {
initMarkupRefineBehaviors(fragment);
},
});Selectors, validation statuses, credentials, redirect handling, headers, result parsing, and post-render initialization are JavaScript configuration. Same-origin and modal-only remote surfaces are stable invariants rather than configurable policy. HTML only identifies enhancement targets and carries normal web semantics.
Remote navigation decision
The Phase 7 remote module passes the stabilization gate as a focused optional export in the same package. Its stable scope is deliberately narrow: same-origin server-rendered HTML, modal subinteractions, ordinary links/forms as the baseline, validation-fragment replacement, and Layer accept()/dismiss() outcomes. It does not become a router, history framework, generic fragment engine, or SPA state manager.
Applications that need broad fragment targeting, history management, polling, navigation orchestration, or a general server-driven interaction model should use a dedicated library such as Unpoly orhtmx rather than growing this module into one.