Htmx: replace hx-select-oob with body swap + hx-preserve#3634
Htmx: replace hx-select-oob with body swap + hx-preserve#3634reakaleek wants to merge 8 commits into
Conversation
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>
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.
This reverts commit 2e8d783.
|
I will probably merge this on monday. (if approved) |
There was a problem hiding this comment.
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-oobcomputation (C# providers + TS strategies/utilities) and switch to body swaps withhx-preserveislands. - Update Razor layouts/components to use
hx-preserveand keeppreloadper-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 topreloadon 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">
| var settings = new OpenApiReaderSettings | ||
| { | ||
| LeaveStreamOpen = false | ||
| LeaveStreamOpen = false, | ||
| RuleSet = ValidationRuleSet.GetEmptyRuleSet() | ||
| }; |
There was a problem hiding this comment.
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() }; |
There was a problem hiding this comment.
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.
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-linkhx-select-oobattributes is gone.Why
Previously, "which regions change when you click this link" was computed twice — once in C# (
Htmx.csproviders) 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-preserveis 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.nav-tree-<id>) +hx-preserve— same group ⇒ id matches ⇒ DOM (and expand/collapse state) preserved; different group ⇒ new tree swaps in./docs/*links on elastic.co navigate through htmx (never/docs/api*— separate app); everything else gets a full page load via ahtmx:beforeRequestguard.elastic-nav.jsmoved to<head>so head-support keeps the identical tag across navigations instead of re-executing it per swap.IHtmxAttributeProviderand 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
navigation testjourney 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/apitriggers a full page load). Runs in CI against the PR's assembler build./pricingand/docs/api/*full-page navigate.dotnet format/tsc/eslint clean.Notes
hx-preserveusesmoveBefore()on Chrome 133+; Safari/Firefox fall back to remove/re-insert, so custom elements get disconnect/reconnect callbacks. Not yet verified there.🤖 Generated with Claude Code