Skip to content

Commit 3d2bbc1

Browse files
baozhoutaoclaude
andauthored
fix(components): route internal html-page links through the SPA navigation handler (#2642)
Authored kind:'html' pages write app-root-relative hrefs (apps/<app>/<object>, docs/<name>). The anchor passthrough rendered them as raw <a> tags, so resolution depended entirely on document.baseURI: correct when the host injected a matching <base> tag (/_console deployments), but nested under the current page path — and 404ing — on deployments without one (root-mounted consoles, vite dev). The anchor renderer now resolves internal hrefs to app-root-absolute paths and navigates via the url action's navigation handler (React Router aware, so the router basename is applied in every deployment), turning full page reloads into SPA navigations as a bonus. External schemes, protocol-relative URLs, in-page fragments, target="_blank", modified clicks, and no-ActionProvider contexts keep native browser behavior; success toasts are suppressed for link follows. Fixes #2638 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 71be244 commit 3d2bbc1

2 files changed

Lines changed: 210 additions & 2 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* kind:'html' anchor navigation (objectui#2638).
11+
*
12+
* Authored pages write app-root-relative hrefs (`apps/<app>/<object>`). A raw
13+
* `<a>` resolves those against `document.baseURI`, which 404s on deployments
14+
* without a host-injected `<base>` tag. The anchor renderer therefore routes
15+
* internal links through the SPA's navigation handler (url action) and leaves
16+
* external / fragment / new-tab / modified-click navigation to the browser.
17+
*/
18+
19+
import { describe, it, expect, beforeAll, vi } from 'vitest';
20+
import { render, fireEvent, waitFor } from '@testing-library/react';
21+
import { SchemaRenderer, ActionProvider } from '@object-ui/react';
22+
import type { NavigationHandler } from '@object-ui/core';
23+
import type { SchemaNode } from '@object-ui/types';
24+
25+
beforeAll(async () => {
26+
await import('../renderers');
27+
}, 30000);
28+
29+
function renderAnchor(
30+
schema: Record<string, unknown>,
31+
onNavigate?: NavigationHandler,
32+
) {
33+
const tree = <SchemaRenderer schema={{ type: 'a', ...schema } as SchemaNode} />;
34+
return render(
35+
onNavigate ? (
36+
<ActionProvider onNavigate={onNavigate}>{tree}</ActionProvider>
37+
) : (
38+
tree
39+
),
40+
);
41+
}
42+
43+
describe('html anchor internal-link navigation', () => {
44+
it('routes app-root-relative hrefs through the navigation handler', async () => {
45+
const onNavigate = vi.fn();
46+
const { getByText } = renderAnchor(
47+
{ href: 'apps/com.example.showcase/showcase_project', children: 'Projects' },
48+
onNavigate,
49+
);
50+
51+
const notPrevented = fireEvent.click(getByText('Projects'));
52+
expect(notPrevented).toBe(false); // default was prevented — no raw browser navigation
53+
54+
await waitFor(() =>
55+
expect(onNavigate).toHaveBeenCalledWith(
56+
'/apps/com.example.showcase/showcase_project',
57+
expect.objectContaining({ external: false, newTab: false }),
58+
),
59+
);
60+
});
61+
62+
it('keeps already-absolute paths as-is and preserves query + hash', async () => {
63+
const onNavigate = vi.fn();
64+
const { getByText } = renderAnchor(
65+
{ href: '/docs/showcase_tour_data?x=1#top', children: 'Tour' },
66+
onNavigate,
67+
);
68+
69+
fireEvent.click(getByText('Tour'));
70+
await waitFor(() =>
71+
expect(onNavigate).toHaveBeenCalledWith(
72+
'/docs/showcase_tour_data?x=1#top',
73+
expect.anything(),
74+
),
75+
);
76+
});
77+
78+
it('resolves ./ and ../ segments against the app root, not the current page', async () => {
79+
const onNavigate = vi.fn();
80+
const { getByText } = renderAnchor(
81+
{ href: '../docs/showcase_index', children: 'Manual' },
82+
onNavigate,
83+
);
84+
85+
fireEvent.click(getByText('Manual'));
86+
await waitFor(() =>
87+
expect(onNavigate).toHaveBeenCalledWith('/docs/showcase_index', expect.anything()),
88+
);
89+
});
90+
91+
it.each([
92+
['https://example.com/x', 'external https'],
93+
['mailto:hi@example.com', 'mailto'],
94+
['//example.com/x', 'protocol-relative'],
95+
['#section', 'in-page fragment'],
96+
])('leaves %s (%s) to the browser', (href) => {
97+
const onNavigate = vi.fn();
98+
const { getByText } = renderAnchor({ href, children: 'Link' }, onNavigate);
99+
100+
const el = getByText('Link');
101+
// Prevent jsdom "navigation not implemented" noise without affecting the
102+
// renderer's own handler (capture runs on the container, after bubbling).
103+
el.ownerDocument.addEventListener('click', (e) => e.preventDefault());
104+
fireEvent.click(el);
105+
106+
expect(onNavigate).not.toHaveBeenCalled();
107+
});
108+
109+
it('does not intercept target="_blank" links', () => {
110+
const onNavigate = vi.fn();
111+
const { getByText } = renderAnchor(
112+
{ href: 'apps/x/y', target: '_blank', children: 'New tab' },
113+
onNavigate,
114+
);
115+
116+
const el = getByText('New tab');
117+
el.ownerDocument.addEventListener('click', (e) => e.preventDefault());
118+
fireEvent.click(el);
119+
120+
expect(onNavigate).not.toHaveBeenCalled();
121+
});
122+
123+
it('does not intercept modified clicks (cmd/ctrl-click)', () => {
124+
const onNavigate = vi.fn();
125+
const { getByText } = renderAnchor(
126+
{ href: 'apps/x/y', children: 'Modified' },
127+
onNavigate,
128+
);
129+
130+
const el = getByText('Modified');
131+
el.ownerDocument.addEventListener('click', (e) => e.preventDefault());
132+
fireEvent.click(el, { metaKey: true });
133+
fireEvent.click(el, { ctrlKey: true });
134+
135+
expect(onNavigate).not.toHaveBeenCalled();
136+
});
137+
138+
it('renders untouched without an ActionProvider', () => {
139+
const { getByText } = renderAnchor({
140+
href: 'apps/x/y',
141+
children: 'Bare',
142+
});
143+
144+
const el = getByText('Bare') as HTMLAnchorElement;
145+
expect(el.getAttribute('href')).toBe('apps/x/y');
146+
el.ownerDocument.addEventListener('click', (e) => e.preventDefault());
147+
expect(() => fireEvent.click(el)).not.toThrow();
148+
});
149+
});

packages/components/src/renderers/basic/html-elements.tsx

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
* neutralizes `javascript:`/`data:` URLs on `<a href>`.
2222
*/
2323

24-
import { ComponentRegistry } from '@object-ui/core';
24+
import { ComponentRegistry, type ActionResult } from '@object-ui/core';
25+
import { ActionCtxReact } from '@object-ui/react';
2526
import { renderChildren } from '../../lib/utils';
26-
import { createElement, forwardRef } from 'react';
27+
import { createElement, forwardRef, useContext } from 'react';
28+
import type { MouseEventHandler } from 'react';
2729

2830
type AnyProps = { schema: any; className?: string; [key: string]: any };
2931

@@ -70,6 +72,34 @@ function sanitizeHref(value: unknown): string | undefined {
7072
return v;
7173
}
7274

75+
/**
76+
* Resolve an authored href to an app-root-absolute path (`/apps/...`) when it
77+
* is an internal SPA link, or null when the browser should handle it natively
78+
* (scheme-qualified, protocol-relative, or in-page fragment links).
79+
*
80+
* Authored pages write app-root-relative hrefs (`apps/<app>/<object>`,
81+
* `docs/<name>`). A raw `<a>` resolves those against `document.baseURI`,
82+
* which only lands on the app root when the host injected a matching `<base>`
83+
* tag — without one (root-mounted consoles, vite dev) the browser nests the
84+
* path under the current page URL and 404s (objectui#2638). Routing internal
85+
* links through the SPA's navigation handler makes them deployment-agnostic.
86+
*/
87+
function toInternalPath(href: unknown): string | null {
88+
if (typeof href !== 'string' || href.trim().length === 0) return null;
89+
const v = href.trim();
90+
if (/^[a-z][a-z0-9+.-]*:/i.test(v)) return null; // http:, https:, mailto:, tel:, …
91+
if (v.startsWith('//')) return null; // protocol-relative external
92+
if (v.startsWith('#')) return null; // in-page fragment
93+
try {
94+
// Resolve ./ and ../ segments against a synthetic root so the result is
95+
// app-root-absolute regardless of the current page path.
96+
const u = new URL(v, 'https://internal.invalid/');
97+
return u.pathname + u.search + u.hash;
98+
} catch {
99+
return null;
100+
}
101+
}
102+
73103
for (const tag of TAGS) {
74104
const isVoid = VOID_TAGS.has(tag);
75105
const Component = forwardRef<HTMLElement, AnyProps>(({ schema, className, ...props }, ref) => {
@@ -91,12 +121,41 @@ for (const tag of TAGS) {
91121
}
92122
if (tag === 'a' && 'href' in safe) safe.href = sanitizeHref(safe.href);
93123

124+
// Internal links navigate through the SPA router (via the url action's
125+
// navigation handler) instead of a raw browser navigation, so they no
126+
// longer depend on a host-injected <base> tag (objectui#2638). Modified
127+
// clicks, new-tab targets, fragments, and external URLs keep native
128+
// behavior; without an ActionProvider the anchor stays untouched.
129+
const actionCtx = useContext(ActionCtxReact);
130+
let onClick: MouseEventHandler | undefined;
131+
if (tag === 'a' && actionCtx) {
132+
const path = toInternalPath(safe.href);
133+
const linkTarget = typeof safe.target === 'string' ? safe.target : '';
134+
if (path && (!linkTarget || linkTarget === '_self')) {
135+
onClick = (e) => {
136+
if (e.defaultPrevented || e.button !== 0) return;
137+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
138+
e.preventDefault();
139+
// The url action keeps SPA-vs-full-page semantics (`/api/` paths
140+
// escape the router); with no navigation handler wired it reports
141+
// the redirect back for us to follow. Success toasts are suppressed
142+
// — following a link is not a feedback-worthy "action".
143+
void actionCtx.runner
144+
.execute({ type: 'url', target: path, toast: { showOnSuccess: false } })
145+
.then((r: ActionResult) => {
146+
if (r?.success && r.redirect) window.location.assign(r.redirect);
147+
});
148+
};
149+
}
150+
}
151+
94152
return createElement(
95153
tag,
96154
{
97155
ref,
98156
className,
99157
...safe,
158+
...(onClick ? { onClick } : {}),
100159
'data-obj-id': dataObjId,
101160
'data-obj-type': dataObjType,
102161
style,

0 commit comments

Comments
 (0)