Skip to content
Open
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
39 changes: 32 additions & 7 deletions packages/bindx-react/src/jsx/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,15 @@ export function analyzeJsx(node: ReactNode, selection: SelectionMetaCollector):
return nestedSelection
}

// Get selection info from the component
const fieldSelection = provider.getSelection(element.props, collectNested)
// Get selection info from the component. A throw here must not silently
// drop the sibling components' selections — report it and continue.
let fieldSelection: ReturnType<SelectionProvider['getSelection']>
try {
fieldSelection = provider.getSelection(element.props, collectNested)
} catch (error) {
reportAnalysisError(component, error)
return
}

// Add to selection
if (fieldSelection) {
Expand All @@ -85,11 +92,15 @@ export function analyzeJsx(node: ReactNode, selection: SelectionMetaCollector):
'staticRender' in component &&
typeof (component as { staticRender: unknown }).staticRender === 'function'
) {
const staticJsx = (component as { staticRender: (props: Record<string, unknown>) => ReactNode }).staticRender(
element.props as Record<string, unknown>,
)
if (staticJsx) {
analyzeJsx(staticJsx, selection)
try {
const staticJsx = (component as { staticRender: (props: Record<string, unknown>) => ReactNode }).staticRender(
element.props as Record<string, unknown>,
)
if (staticJsx) {
analyzeJsx(staticJsx, selection)
}
} catch (error) {
reportAnalysisError(component, error)
}
return
}
Expand All @@ -102,6 +113,20 @@ export function analyzeJsx(node: ReactNode, selection: SelectionMetaCollector):
}
}

/**
* Reports a selection-analysis failure of a single component with attribution,
* so one broken component doesn't silently cost its siblings their selection.
*/
function reportAnalysisError(component: unknown, error: unknown): void {
const c = component as { displayName?: string; name?: string; type?: { displayName?: string; name?: string } }
const name = c.displayName ?? c.type?.displayName ?? (c.name || c.type?.name) ?? 'anonymous'
console.error(
`[bindx] Selection analysis of <${name}> failed — its fields were NOT added to the fetch plan. `
+ 'Fix the error below; fields it uses may be missing from queries until then.',
error,
)
}

/**
* Collects all field selections from a JSX tree.
* Entry point for the collection phase.
Expand Down
19 changes: 19 additions & 0 deletions packages/bindx-react/src/jsx/componentBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class ComponentBuilderImpl<
private readonly hasInterfacesMode: boolean = false,
private readonly conditionFn: ((props: Record<string, unknown>) => Condition) | null = null,
private readonly slotNames: readonly string[] = ['children'],
private readonly useFns: readonly ((props: Record<string, unknown>) => object)[] = [],
) {}

entity(
Expand All @@ -67,6 +68,7 @@ export class ComponentBuilderImpl<
this.hasInterfacesMode,
this.conditionFn,
this.slotNames,
this.useFns,
)
}

Expand Down Expand Up @@ -96,6 +98,7 @@ export class ComponentBuilderImpl<
true, // Enable interfaces mode for discovery of implicit interface props
this.conditionFn,
this.slotNames,
this.useFns,
)
}

Expand All @@ -108,6 +111,19 @@ export class ComponentBuilderImpl<
this.hasInterfacesMode,
this.conditionFn,
this.slotNames,
this.useFns,
)
}

use(useFn: (props: Record<string, unknown>) => object): ComponentBuilderImpl<TState> {
return new ComponentBuilderImpl(
this.schemaRegistry,
this.entityConfigs,
this.roles,
this.hasInterfacesMode,
this.conditionFn,
this.slotNames,
[...this.useFns, useFn],
)
}

Expand All @@ -119,6 +135,7 @@ export class ComponentBuilderImpl<
this.hasInterfacesMode,
conditionFn,
this.slotNames,
this.useFns,
)
}

Expand All @@ -130,6 +147,7 @@ export class ComponentBuilderImpl<
this.hasInterfacesMode,
this.conditionFn,
names,
this.useFns,
)
}

Expand All @@ -142,6 +160,7 @@ export class ComponentBuilderImpl<
this.schemaRegistry,
this.conditionFn,
this.slotNames,
this.useFns,
)
}
}
Expand Down
69 changes: 63 additions & 6 deletions packages/bindx-react/src/jsx/componentBuilder.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,13 @@ export interface ComponentBuilderState<
TEntityProps extends Record<string, AnyEntityPropConfig> = {},
TScalarProps extends object = object,
TRoles extends readonly string[] = readonly string[],
// eslint-disable-next-line @typescript-eslint/ban-types
TUseProps extends object = {},
> {
readonly __entityProps: TEntityProps
readonly __scalarProps: TScalarProps
readonly __roles: TRoles
readonly __useProps: TUseProps
}

// ============================================================================
Expand All @@ -130,7 +133,8 @@ export type AddImplicitEntity<
readonly [K in TPropName]: ImplicitEntityConfig<TEntity>
},
TState['__scalarProps'],
TState['__roles']
TState['__roles'],
TState['__useProps']
>

/**
Expand All @@ -147,7 +151,8 @@ export type AddExplicitEntity<
readonly [K in TPropName]: ExplicitEntityConfig<TEntity, TSelected>
},
TState['__scalarProps'],
TState['__roles']
TState['__roles'],
TState['__useProps']
>

/**
Expand All @@ -162,7 +167,8 @@ export type AddImplicitInterfaceEntity<
readonly [K in TPropName]: ImplicitInterfaceEntityConfig<TInterface>
},
TState['__scalarProps'],
TState['__roles']
TState['__roles'],
TState['__useProps']
>

/**
Expand All @@ -177,7 +183,8 @@ export type AddExplicitInterfaceEntity<
readonly [K in TPropName]: ExplicitInterfaceEntityConfig<TInterface>
},
TState['__scalarProps'],
TState['__roles']
TState['__roles'],
TState['__useProps']
>

/**
Expand All @@ -192,7 +199,8 @@ export type AddInterfaces<
readonly [K in keyof TInterfaces]: InterfaceEntityPropConfig<TInterfaces[K]>
},
TState['__scalarProps'],
TState['__roles']
TState['__roles'],
TState['__useProps']
>

/**
Expand All @@ -214,7 +222,21 @@ export type SetScalarProps<
> = ComponentBuilderState<
TState['__entityProps'],
TNewScalarProps,
TState['__roles']
TState['__roles'],
TState['__useProps']
>

/**
* Merge runtime-only values from .use() into builder state.
*/
export type AddUseProps<
TState extends ComponentBuilderState,
TUse extends object,
> = ComponentBuilderState<
TState['__entityProps'],
TState['__scalarProps'],
TState['__roles'],
TState['__useProps'] & TUse
>

// ============================================================================
Expand Down Expand Up @@ -273,9 +295,12 @@ export type BuildProps<TState extends ComponentBuilderState> =

/**
* Build complete render props type from builder state.
* Includes .use() values — they exist only inside the render function,
* never on the public props (BuildProps).
*/
export type BuildRenderProps<TState extends ComponentBuilderState> =
TState['__scalarProps'] &
TState['__useProps'] &
BuildRenderEntityProps<TState['__entityProps'], TState['__roles']>

/**
Expand Down Expand Up @@ -425,6 +450,38 @@ export interface ComponentBuilder<
*/
props<TNewScalarProps extends object>(): ComponentBuilder<SetScalarProps<TState, TNewScalarProps>>

/**
* Provide runtime-only values to the render function — hooks are allowed here.
*
* The function runs during every React render (inside the component), so
* `useT()`, `useContext()`, `useMemo()` etc. all work. It is NEVER executed
* during static selection analysis — its outputs are replaced by inert
* stand-ins there, so the render body can use them freely without crashing
* the collection pass.
*
* This removes the need for thin wrapper components that only thread
* hook-derived values in as props (which also break selection discovery,
* because the JSX walk cannot see through plain components).
*
* Can be chained; later use() functions see the values of earlier ones.
*
* @example
* ```typescript
* createComponent()
* .entity('article', schema.Article)
* .use(() => ({ t: useTranslator() }))
* .render(({ article, t }) => (
* <div>
* <h2>{t('article.heading')}</h2>
* <Field field={article.title} />
* </div>
* ))
* ```
*/
use<TUse extends object>(
useFn: (props: BuildRenderProps<TState>) => TUse,
): ComponentBuilder<AddUseProps<TState, TUse>>

/**
* Add a condition that must be true for the component to render.
* If the condition is false, the component renders null.
Expand Down
Loading