Skip to content

Htmx: replace hx-select-oob with body swap + hx-preserve#3634

Open
reakaleek wants to merge 8 commits into
mainfrom
equinox-kumquat
Open

Htmx: replace hx-select-oob with body swap + hx-preserve#3634
reakaleek wants to merge 8 commits into
mainfrom
equinox-kumquat

Conversation

@reakaleek

@reakaleek reakaleek commented Jul 9, 2026

Copy link
Copy Markdown
Member

What

Replaces the site's hand-rolled htmx navigation model with htmx's native one: boosted links do the default whole-body swap, and stable islands (nav tree, headers, search, ask-ai) are marked hx-preserve. All the machinery that used to compute per-link hx-select-oob attributes is gone.

Why

Previously, "which regions change when you click this link" was computed twice — once in C# (Htmx.cs providers) and once in TS (strategies/*) — and every anchor had to carry the right oob string at render time. Forgetting a region meant silently stale content, and the two implementations could drift out of sync with each other.

Inverting the default flips the failure mode: new page regions are correct by default (they're just part of the swapped body), and a forgotten hx-preserve is a visible island reset instead of quietly stale data. It also deletes the dual-computed oob logic entirely rather than keeping it in sync — this is the htmx-native way to do boosted navigation with persistent UI chrome, not a workaround.

How

  • hx-swap="none" removed from <body>; boosted links swap the whole body.
  • Same-top-level-group nav-tree behavior is now structural instead of computed: the tree gets a group-scoped id (nav-tree-<id>) + hx-preserve — same group ⇒ id matches ⇒ DOM (and expand/collapse state) preserved; different group ⇒ new tree swaps in.
  • Boost is scoped: only /docs/* links on elastic.co navigate through htmx (never /docs/api* — separate app); everything else gets a full page load via a htmx:beforeRequest guard.
  • elastic-nav.js moved to <head> so head-support keeps the identical tag across navigations instead of re-executing it per swap.
  • IHtmxAttributeProvider and the per-link oob-computation plumbing (C# providers, TS strategy dead members, view-model threading) are deleted, not just stubbed — this PR removes that code rather than leaving a follow-up.

Test plan

  • Synthetics navigation test journey extended to assert the SPA model (no reload across same/cross-group navs, nav-tree preservation incl. checkbox state, cross-group tree replacement, elastic-nav.js fetched once, /docs/api triggers a full page load). Runs in CI against the PR's assembler build.
  • Runtime-verified against a full local assembler build: same-group nav preserves tree DOM + state, cross-group swaps in the new tree, back/forward, modifier-click, /pricing and /docs/api/* full-page navigate.
  • All C# and JS tests pass; dotnet format/tsc/eslint clean.

Notes

  • hx-preserve uses moveBefore() on Chrome 133+; Safari/Firefox fall back to remove/re-insert, so custom elements get disconnect/reconnect callbacks. Not yet verified there.
  • Don't push the updated synthetics monitor to prod/staging until this ships there — the merged journey asserts the new SPA behavior under the existing monitor id.
  • Codex/isolated builds compile and are covered by unit tests but weren't runtime-verified.

🤖 Generated with Claude Code

Boosted links now use htmx's default whole-body swap; stable islands
(nav tree, headers, search, ask-ai) are marked hx-preserve. The
same-top-level-group nav-tree condition becomes structural: group-scoped
ids (nav-tree-<id>) with hx-preserve id-matching preserve the tree
within a group and swap it across groups. Boost is scoped to /docs/*
elastic.co links (excluding /docs/api) via a beforeRequest guard, and
elastic-nav.js moves to <head> so head-support keeps it from
re-executing per navigation. Synthetics journey extended to assert the
SPA behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@reakaleek reakaleek requested a review from a team as a code owner July 9, 2026 19:44
@reakaleek reakaleek requested a review from technige July 9, 2026 19:44
@reakaleek reakaleek marked this pull request as draft July 9, 2026 19:54
assemblerStrategy hardcoded '/docs' as the docs path prefix, but that's
only true on production - PR previews serve under a prefix like
/elastic/docs-builder/docs/3634. Every link's getPathFromUrl returned
null there, so the beforeRequest guard treated every internal link as
external and forced a full page reload on every click (the reported
nav-tree flash was a real page load, not an hx-preserve issue).

Use config.rootPath (already injected per-build) instead. Also drops
getFirstSegment/isSimpleSwapPath - dead interface members with no
callers that would have carried the same hardcoding bug forward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
IHtmxAttributeProvider/DefaultHtmxAttributeProvider/CodexHtmxAttributeProvider
existed to compute hx-select-oob and vary it by build type. Since the body
swap + hx-preserve model landed, GetHxAttributes/GetNavHxAttributes only ever
emitted a preload attribute - hasSameTopLevelGroup and hxSwapOob were accepted
and silently ignored, GetHxSelectOob had no callers left, and
CodexHtmxAttributeProvider's only override (of GetHxSelectOob) was dead too.

Replaced the whole interface/provider/facade stack with a plain RootPath
string on the view models that need it (for data-root-path) and a static
Htmx.Attributes(preload) helper for the handful of call sites that render
the preload attribute. Also removes a ParserContext.Htmx property and a
link.SetData(nameof(IHtmxAttributeProvider), ...) write that had no reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The beforeRequest guard ran on every htmx GET, including the preload
extension's speculative prefetches - preloading a non-docs link fired a
real window.location.assign on mousedown/hover. Exempt HX-Preloaded
requests and scope the guard to boosted navigation so future hx-get
widgets aren't hijacked either.

assemblerStrategy now also accepts absolute self-links via an origin
check (previews and white-label hosts aren't elastic.co, so their
absolute links fell back to full page loads), and hoists rootPath
normalization to module scope instead of recomputing per link.

Cleanup: data-root-path had no readers left (the frontend reads
config.rootPath from __DOCS_CONFIG__), so delete it and its whole feed
chain - GlobalLayoutViewModel.RootPath, IndexViewModel.SiteRootPath,
and four identical GetRootPath/GetDefaultRootPath copies. Inline the
one-constant Htmx.Attributes() helper as literal preload attributes,
drop the write-only NavigationTreeItem.RootNavigationId, and update
hook JSDoc that still described the deleted hx-select-oob model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every click on a nav link expanded its ancestor folders but never
collapsed folders left open from earlier navigations, so the tree
kept growing (and shifting layout) the more you clicked around.
Now only the current item's ancestor path stays expanded.
@reakaleek reakaleek requested review from Mpdreamz and Copilot July 10, 2026 09:17
@reakaleek reakaleek self-assigned this Jul 10, 2026
@reakaleek reakaleek marked this pull request as ready for review July 10, 2026 09:22
@reakaleek

reakaleek commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

I will probably merge this on monday. (if approved)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR migrates docs navigation from a per-link, server/client-computed hx-select-oob model to htmx’s native boosted navigation: whole-<body> swaps with persistent UI “islands” marked hx-preserve. It removes the C# and TS plumbing that previously computed swap regions per link, and updates synthetics/tests to validate the new SPA-like behavior.

Changes:

  • Remove per-link hx-select-oob computation (C# providers + TS strategies/utilities) and switch to body swaps with hx-preserve islands.
  • Update Razor layouts/components to use hx-preserve and keep preload per-link where needed.
  • Extend synthetics navigation journey and adjust unit/integration tests to assert the new navigation model; additionally disable OpenAPI reader validation via an empty ruleset.

Reviewed changes

Copilot reviewed 47 out of 47 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/Elastic.Markdown.Tests/Codex/CodexHtmxCrossLinkTests.cs Updates expectations: no per-link hx-select-oob, keep preload.
tests/Elastic.Markdown.Tests/Assembler/AssemblerHtmxMarkdownLinkTests.cs Updates expectations for assembler links: no hx-select-oob, keep preload.
tests-integration/Elastic.Documentation.IntegrationTests/NavigationBuildingTests.cs Removes now-deleted HTMX provider wiring from integration test setup.
src/services/Elastic.Documentation.Assembler/Navigation/GlobalNavigationHtmlWriter.cs Stops threading HTMX provider into navigation view model.
src/Elastic.Markdown/Page/IndexViewModel.cs Removes SiteRootPath now-unused by HTMX provider model.
src/Elastic.Markdown/Page/Index.cshtml Removes root-path/HTMX provider construction from page layout model.
src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs Drops per-link hx-select-oob emission; keeps per-link preload.
src/Elastic.Markdown/Myst/ParserContext.cs Removes per-build HTMX provider creation from parser context.
src/Elastic.Markdown/Myst/InlineParsers/DiagnosticLinkInlineParser.cs Stops attaching HTMX provider data onto links.
src/Elastic.Markdown/Myst/Directives/PageCard/PageCardView.cshtml Removes hx-select-oob from page cards; keeps preload.
src/Elastic.Markdown/Layout/_PrevNextNav.cshtml Replaces computed HTMX attrs with per-link preload only.
src/Elastic.Markdown/Layout/_LandingPage.cshtml Removes OOB swap markup from landing page container.
src/Elastic.Markdown/Layout/_Breadcrumbs.cshtml Removes computed HTMX attrs from breadcrumbs; keeps preload.
src/Elastic.Markdown/HtmlWriter.cs Stops populating SiteRootPath in the index view model.
src/Elastic.Documentation.Site/synthetics/journeys/navigation-test.journey.ts Extends synthetics journey to assert SPA/no-reload + preserve/replace semantics.
src/Elastic.Documentation.Site/Navigation/NavigationViewModel.cs Removes required HTMX provider from navigation VM.
src/Elastic.Documentation.Site/Navigation/NavigationTreeItem.cs Simplifies tree item VM by removing HTMX/provider fields.
src/Elastic.Documentation.Site/Navigation/IsolatedBuildNavigationHtmlWriter.cs Removes HTMX provider creation and wiring for isolated builds.
src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml Removes HTMX-related parameters from recursive tree rendering.
src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml Adds group-scoped preserved nav tree id and hx-preserve islands; removes per-link HTMX attrs.
src/Elastic.Documentation.Site/Layout/_SecondaryNav.cshtml Preserves ask-ai and removes OOB swapping + per-link HTMX attrs.
src/Elastic.Documentation.Site/Layout/_IsolatedHeader.cshtml Marks header as hx-preserve for body swaps.
src/Elastic.Documentation.Site/Layout/_Head.cshtml Moves elastic-nav.js into <head> for head-support merging across navigations.
src/Elastic.Documentation.Site/Layout/_AssemblerHeader.cshtml Preserves elastic nav wrapper; removes per-swap script injection.
src/Elastic.Documentation.Site/IHtmxAttributeProvider.cs Deletes HTMX attribute provider interface (no longer needed).
src/Elastic.Documentation.Site/Htmx.cs Replaces provider classes with a small constant holder (Preload).
src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/utils.ts Removes per-link HTMX attribute application helpers; keeps URL normalization surface.
src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxLink.ts Switches hook to normalize href + hx-disable external docs, and rely on body hx-boost.
src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxLink.test.tsx Updates URL normalization tests (e.g., reject non-docs same-origin paths).
src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/useHtmxContainer.ts Removes per-link attribute application; processes links to inherit body boost.
src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/types.ts Removes no-longer-needed strategy methods (segment/simple swap).
src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/isolated.ts Simplifies isolated strategy to path normalization only.
src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/codex.ts Simplifies codex strategy to path normalization only.
src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.ts Updates assembler strategy to respect configurable rootPath and exclude /api sub-app.
src/Elastic.Documentation.Site/Assets/web-components/shared/htmx/strategies/assembler.test.ts Adds unit tests for assembler strategy with prefixed preview rootPath.
src/Elastic.Documentation.Site/Assets/main.ts Scopes boosted navigation to docs paths and adds body-swap scroll-to-top behavior.
src/Elastic.Documentation.Site/_ViewModels.cs Removes required HTMX provider from global layout model.
src/Elastic.Documentation.Site/_GlobalLayout.cshtml Switches to body swap (removes hx-swap="none" + root-path data attribute).
src/Elastic.Codex/Page/Index.cshtml Removes HTMX provider construction from codex index layout model.
src/Elastic.Codex/Layout/_CodexSubHeader.cshtml Removes hx-select-oob from codex breadcrumbs/back links; keeps preload.
src/Elastic.Codex/Layout/_CodexHeader.cshtml Preserves codex search bar with hx-preserve; removes OOB swap attrs.
src/Elastic.Codex/Landing/LandingView.cshtml Removes OOB swap attrs from codex landing and docset cards; keeps preload where relevant.
src/Elastic.Codex/Landing/_CodexCard.cshtml Removes hx-select-oob from codex cards; keeps preload.
src/Elastic.Codex/CodexViewModel.cs Removes HTMX provider/root-path computation from codex global layout model creation.
src/Elastic.ApiExplorer/Model/OpenApiReader.cs Disables OpenAPI validation by setting an empty validation ruleset.
src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs Removes HTMX provider/root-path computation from API layout model creation.
src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs Disables OpenAPI validation for fetched specs via empty validation ruleset.
Comments suppressed due to low confidence (1)

src/Elastic.Documentation.Site/Navigation/_TocTree.cshtml:48

  • preload="mouseover" is currently set on the <ul> element, but the htmx preload extension only reacts to preload on the triggering element (typically the <a>). As written, dropdown items won’t be preloaded on hover. Move the attribute to the <a> tags (and drop it from the <ul>).
					<div class="hidden group-focus-within:block left-0 right-0 absolute top-full">
						<ul class="mt-1 py-2 bg-white border-1 border-grey-20 rounded-sm shadow-md"
						preload="mouseover">
				@foreach (var item in Model.TopLevelItems)
				{
								<li class="block">
									<a
										class="block py-2 px-4 hover:underline hover:text-black hover:bg-grey-10 active:bg-blue-elastic-70 active:text-white font-semibold @(item.NavigationRoot.Id == Model.Tree.Id ? "text-blue-elastic" : "")"
										href="@item.Url">

Comment thread src/Elastic.Markdown/Myst/Renderers/HtmxLinkInlineRenderer.cs Outdated
Comment on lines 19 to 23
var settings = new OpenApiReaderSettings
{
LeaveStreamOpen = false
LeaveStreamOpen = false,
RuleSet = ValidationRuleSet.GetEmptyRuleSet()
};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No change needed — this already compiles. In the referenced Microsoft.OpenApi package version (3.5.4), ValidationRuleSet lives in the Microsoft.OpenApi namespace, not Microsoft.OpenApi.Validations — both files already have using Microsoft.OpenApi; at the top. Verified with a clean dotnet build of Elastic.ApiExplorer.


await using var stream = await response.Content.ReadAsStreamAsync(ctx);
var settings = new OpenApiReaderSettings { LeaveStreamOpen = false };
var settings = new OpenApiReaderSettings { LeaveStreamOpen = false, RuleSet = ValidationRuleSet.GetEmptyRuleSet() };

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Same as the OpenApiReader.cs thread — ValidationRuleSet is in the Microsoft.OpenApi namespace in this package version (3.5.4), which is already imported here. Build succeeds as-is, no change needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants