Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions framework/framework/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => {
const inject = request.headers['x-hydro-inject'].toString().toLowerCase().split(',').map((i) => i.trim());
if (inject.includes('pagename')) {
ctx.set('x-hydro-page', ctx._matchedRouteName || '');
ctx.set('x-hydro-template', response.template || '');
}
if (response.body !== null && typeof response.body === 'object') {
if (inject.includes('uicontext')) response.body.UiContext = UiContext;
Expand Down
2 changes: 2 additions & 0 deletions packages/ui-next/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export async function apply(ctx: Context) {
const serialized = JSON.stringify({
HYDRO_INJECTED: true,
name: context.handler.context._matchedRouteName,
template: context.handler.response.template || '',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize injected template to match navigation header contract.

Line 194 and Line 228 inject context.handler.response.template verbatim, but navigation reads x-hydro-template from framework/framework/base.ts Line 87 where .html is stripped. This creates inconsistent PageData.template values between first render and client navigation, which can resolve different page:* entries/layouts for the same route.

Proposed fix
-                    template: context.handler.response.template || '',
+                    template: (context.handler.response.template || '').replace(/\.html$/, ''),
...
-                    template: context.handler.response.template || '',
+                    template: (context.handler.response.template || '').replace(/\.html$/, ''),

Also applies to: 228-228

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui-next/index.ts` at line 194, The injected template values at line
194 and line 228 in the file are inconsistent with how navigation normalizes
templates in framework/base.ts. Both instances currently inject
context.handler.response.template verbatim, but the navigation header contract
strips the .html extension from the template name. Normalize both injections by
applying the same .html stripping logic that the navigation code uses, ensuring
PageData.template values remain consistent between first render and client
navigation to prevent resolving different page layouts for the same route.

args: {
UserContext: context.UserContext,
UiContext: context.handler.UiContext,
Expand Down Expand Up @@ -224,6 +225,7 @@ export async function apply(ctx: Context) {
const serialized = JSON.stringify({
HYDRO_INJECTED: true,
name: context.handler.context._matchedRouteName,
template: context.handler.response.template || '',
args: {
UserContext: context.UserContext,
UiContext: context.handler.UiContext,
Expand Down
45 changes: 37 additions & 8 deletions packages/ui-next/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,36 @@
import { Suspense, useMemo, useSyncExternalStore } from 'react';
import DefaultLayout from './components/layout';
import { usePageData } from './context/page-data';
import { defineSlot, SlotName } from './registry';
import { defineSlot } from './registry';
import { SlotErrorBoundary } from './registry/error-boundary';
import { store } from './registry/store';

const App = defineSlot('app:root', () => {
const { name } = usePageData();
const slotName: SlotName = `page:${name}`;
const { name, template, args } = usePageData();

const isError = !!(args as Record<string, unknown>).error;

const [slotName, entry] = useMemo(() => {
if (isError) {
return ['page:error', store.getDefault('page:error')] as const;
}
const templateName = typeof template === 'string' ? template.replace(/\.html$/, '') : null;
if (templateName) {
const templateSlot = `page:${templateName}` as `page:${string}`;
const templateEntry = store.getDefault(templateSlot);
if (templateEntry) {
if (import.meta.env.DEV) {
console.log(`[ui-next] using template "${templateName}" for page "${name}"`);
}
return [templateSlot, templateEntry] as const;
}
}
const slot = `page:${name}` as `page:${string}`;
if (import.meta.env.DEV) {
console.log(`[ui-next] using page slot "${slot}"`);
}
return [slot, store.getDefault(slot)] as const;
}, [name, template, isError]);

const [subscribe, getSnapshot] = useMemo(() => [
(cb: () => void) => store.subscribe(slotName, cb),
Expand All @@ -15,20 +39,25 @@ const App = defineSlot('app:root', () => {

useSyncExternalStore(subscribe, getSnapshot);

const Comp = store.getDefault(slotName);

if (!Comp) {
if (!entry) {
return (
<div>
Page not found: <code>{name}</code>
</div>
);
}

const Layout = store.getDefault(`layout:${entry.layout}`) ?? DefaultLayout;
const { Page } = entry;

return (
<SlotErrorBoundary slotName={slotName} label="renderer">
<Suspense fallback={<div>Loading...</div>}>
<Comp />
<Suspense fallback={null}>
<Layout>
<Suspense fallback={<div>Loading...</div>}>
<Page />
</Suspense>
</Layout>
</Suspense>
</SlotErrorBoundary>
);
Expand Down
5 changes: 4 additions & 1 deletion packages/ui-next/src/components/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { defineSlot } from '../registry';

const Layout = defineSlot('app:layout', ({ children }: React.PropsWithChildren) => <>{children}</>);
const Layout = defineSlot('layout:default', ({ children }: React.PropsWithChildren) => {
console.log('[ui-next] using default layout');
return <>{children}</>;
});

export default Layout;
1 change: 1 addition & 0 deletions packages/ui-next/src/context/page-data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createContext, type ReactNode, useContext, useMemo, useState } from 're

export interface PageData {
name: string;
template: string;
args: {
UserContext: Record<string, any>;
UiContext: Record<string, any>;
Expand Down
2 changes: 2 additions & 0 deletions packages/ui-next/src/context/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const RouterProvider: React.FC<React.PropsWithChildren> = ({ children })
if (!res.ok) throw new Error(`Navigation failed: ${res.status} ${res.statusText}`);
const body = await res.json();
const pageName = res.headers.get('x-hydro-page') || '';
const template = res.headers.get('x-hydro-template') || '';
console.log('[Hydro] data from', reqUrl, 'received:', body, 'pageName:', pageName);

if (gen !== genRef.current) return false;
Expand All @@ -95,6 +96,7 @@ export const RouterProvider: React.FC<React.PropsWithChildren> = ({ children })
...prev,
args: body,
name: pageName,
template,
url,
}));
dispatch({ type: 'FETCH_SUCCESS' });
Expand Down
1 change: 1 addition & 0 deletions packages/ui-next/src/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const endpointOrigins = new Set(endpoints.map((ep) => new URL(ep).origin)

export const initialPage: PageData = {
name: (injectionData.name as string) || '',
template: (injectionData.template as string) || '',
args: (injectionData.args as any) || {},
url: (injectionData.url as string) || (window.location.pathname + window.location.search),
};
9 changes: 0 additions & 9 deletions packages/ui-next/src/pages/homepage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,3 @@ export default function Homepage() {
</div>
);
}

export function Layout({ children }: React.PropsWithChildren) {
return (
<div>
{children}
<div>layout test</div>
</div>
);
}
7 changes: 6 additions & 1 deletion packages/ui-next/src/registry/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
export { SlotErrorBoundary } from './error-boundary';
export { after, before, intercept, patch, replace, wrap } from './interceptors';
export { registerLayout } from './layout';
export type { LayoutComponent } from './layout';
export { registerPage } from './page';
export { installPlugin } from './plugin';
export type { PluginAPI, PluginDefinition } from './plugin';
export { defineSlot } from './slot';
export type { Interceptor, InterceptorEntry, InterceptorOptions, SlotName } from './types';
export type {
AppSlotName, ComponentSlotName, Interceptor, InterceptorEntry, InterceptorOptions,
LayoutSlotName, PageEntry, PageSlotName, RegisterPageOptions, SlotName, SlotValue,
} from './types';
7 changes: 7 additions & 0 deletions packages/ui-next/src/registry/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { store } from './store';

export type LayoutComponent = React.ComponentType<React.PropsWithChildren>;

export function registerLayout(name: string, Comp: LayoutComponent) {
store.setDefault(`layout:${name}` as `layout:${string}`, Comp);
}
35 changes: 10 additions & 25 deletions packages/ui-next/src/registry/page.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,13 @@
import React, { lazy } from 'react';
import Layout from '../components/layout';
import { lazy } from 'react';
import { store } from './store';
import type { PageEntry, PageLoader, PageSlotName, RegisterPageOptions } from './types';

interface PageModule<P = any> {
default: React.ComponentType<P>;
Layout?: React.ComponentType<React.PropsWithChildren>;
}

type PageLoader<P = any> = () => Promise<PageModule<P>>;

export function registerPage<P = any>(name: string, loader: PageLoader<P>) {
store.setDefault(`page:${name}`, lazy(() =>
loader().then((mod) => {
const PageComp = mod.default;
const LayoutComp = mod.Layout || Layout;
return {
default(props: React.PropsWithChildren<P>) {
return (
<LayoutComp>
<PageComp {...props} />
</LayoutComp>
);
},
};
}),
));
export function registerPage<P = any>(
name: string,
loader: PageLoader<P>,
options: RegisterPageOptions = {},
) {
const Page = lazy(loader);
const entry: PageEntry<P> = { Page, layout: options.layout ?? 'default' };
store.setDefault(`page:${name}` as PageSlotName, entry);
}
7 changes: 6 additions & 1 deletion packages/ui-next/src/registry/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { after, before, intercept, patch, replace, wrap } from './interceptors';
import { registerLayout } from './layout';
import { registerPage } from './page';

export interface PluginAPI {
Expand All @@ -9,10 +10,14 @@ export interface PluginAPI {
replace: typeof replace;
wrap: typeof wrap;
registerPage: typeof registerPage;
registerLayout: typeof registerLayout;
}

export function createPluginAPI(): PluginAPI {
return { intercept, before, after, patch, replace, wrap, registerPage };
return {
intercept, before, after, patch, replace, wrap,
registerPage, registerLayout,
};
}

export interface PluginDefinition {
Expand Down
2 changes: 1 addition & 1 deletion packages/ui-next/src/registry/slot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function buildChain<P extends Record<string, any>>(
return pipeline;
}

export function defineSlot<P extends Record<string, any>>(
export function defineSlot<P extends React.ComponentProps<any>>(
name: SlotName,
DefaultComp: React.FC<P>,
): React.FC<P> {
Expand Down
12 changes: 6 additions & 6 deletions packages/ui-next/src/registry/store.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { InterceptorEntry, InterceptorOptions, SlotName } from './types';
import type { InterceptorEntry, InterceptorOptions, SlotName, SlotValue } from './types';

type Listener = () => void;

interface RegistryState {
interceptors: Record<string, InterceptorEntry[]>;
defaults: Record<string, React.FC<any>>;
defaults: Record<string, unknown>;
}

function createRegistryStore() {
Expand Down Expand Up @@ -72,13 +72,13 @@ function createRegistryStore() {
return state.interceptors[name] ?? [];
}

function setDefault(name: SlotName, comp: React.FC<any>) {
state.defaults[name] = comp;
function setDefault<N extends SlotName>(name: N, value: SlotValue<N>) {
state.defaults[name] = value;
bumpVersion(name);
}

function getDefault(name: SlotName): React.FC<any> | undefined {
return state.defaults[name];
function getDefault<N extends SlotName>(name: N): SlotValue<N> | undefined {
return state.defaults[name] as SlotValue<N> | undefined;
}

return {
Expand Down
33 changes: 30 additions & 3 deletions packages/ui-next/src/registry/types.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,44 @@
type Scope = 'app' | 'page' | 'component' | string;
import type { LayoutComponent } from './layout';

type AppScope = 'app';
type PageScope = 'page';
type LayoutScope = 'layout';
type ComponentScope = 'component';
type Scope = AppScope | PageScope | LayoutScope | ComponentScope | string;

export interface PageModule<P = any> {
default: React.ComponentType<P>;
}
export type PageLoader<P = any> = () => Promise<PageModule<P>>;
export interface PageEntry<P = any> {
Page: React.LazyExoticComponent<React.ComponentType<P>>;
layout: string;
}
export interface RegisterPageOptions {
/** Layout key registered via `registerLayout`. Defaults to `default`. */
layout?: string;
}

export type AppSlotName = `${AppScope}:${string}`;
export type PageSlotName = `${PageScope}:${string}`;
export type LayoutSlotName = `${LayoutScope}:${string}`;
export type ComponentSlotName = `${ComponentScope}:${string}`;
export type SlotName = `${Scope}:${string}`;
export type SlotValue<N extends SlotName> =
N extends PageSlotName ? PageEntry
: N extends LayoutSlotName ? LayoutComponent
: N extends ComponentSlotName ? React.FC<any>
: React.FC<any>;

export type Interceptor<P = any> = (
props: P,
next: (overrideProps?: Partial<P>) => React.ReactNode,
) => React.ReactNode;

export interface InterceptorOptions {
id?: string;
/** 越小越靠外,越先执行 */
priority?: number;
}

export interface InterceptorEntry<P = any> {
id: string;
/** 越小越靠外,越先执行 */
Expand Down
Loading