@primafuture/contrib-kit-react
v1.0.0
Published
React adapter for Contrib Kit with headless outlets, scoped registries, managed lazy loading, and SSR support.
Maintainers
Readme
@primafuture/contrib-kit-react
Typed React contribution components and synchronous resolved Core views for Contrib
Kit. The package provides typed point helpers, a scoped registry provider,
useResolvedContributions(), and a headless ExtensionOutlet with logical placement,
isolated error boundaries, policy, retry, and commit-only managed-lazy loading.
Install the framework-neutral Core and React peers together:
pnpm add @primafuture/contrib-kit-core @primafuture/contrib-kit-react react react-domimport {
defineReactContributionComponent,
defineReactExtensionPoint,
defineReactLazyContribution,
ExtensionOutlet,
ExtensionRegistryProvider,
useResolvedContributions,
} from "@primafuture/contrib-kit-react";
import { createExtensionRegistry } from "@primafuture/contrib-kit-core";
interface PanelContext {
readonly locale: string;
}
interface PanelData {
readonly title: string;
}
const Panel = defineReactContributionComponent(
function Panel(props: {
readonly context: PanelContext;
readonly contribution: {
readonly contributionId: string;
readonly data: PanelData;
};
readonly placement: {
readonly index: number;
readonly count: number;
readonly isFirst: boolean;
readonly isLast: boolean;
};
}) {
return `${props.context.locale}: ${props.contribution.data.title}`;
},
);
const panels = defineReactExtensionPoint<PanelContext, PanelData>()({
pointId: "example.panels",
contractVersion: 1,
cardinality: "many",
});
const lazyPanel = defineReactLazyContribution({
async load() {
return Panel;
},
});
const registry = createExtensionRegistry();
registry.rootScope.registerPoint(panels);
const registration = registry.registerContribution(panels, {
contributionId: "example.panel",
expectedContractVersion: 1,
implementation: lazyPanel,
data: { title: "Overview" },
});
await registration.whenActive();
function PanelList() {
const { state, refresh } = useResolvedContributions({
point: panels,
context: { locale: "en" },
});
if (state.status === "failed") {
return <button onClick={refresh}>Retry resolution</button>;
}
return (
<ul>
{state.contributions.map((contribution) => (
<li key={contribution.registrationId}>{contribution.data.title}</li>
))}
</ul>
);
}
const app = (
<ExtensionRegistryProvider registry={registry}>
<PanelList />
<ExtensionOutlet
point={panels}
context={{ locale: "en" }}
renderLoading={({ contribution }) => (
<span>Loading {contribution.data.title}…</span>
)}
renderContributionError={({ error, retry }) => (
<button onClick={retry}>{error.code}</button>
)}
/>
</ExtensionRegistryProvider>
);
await registry.dispose();The provider owns only React adapter subscriptions; the host still owns and disposes
the Core registry and contribution handles. useResolvedContributions() returns a
frozen synchronous view, classifies empty results as noRegistrations or
excludedByPolicy, and exposes a local refresh() that never commits Core state.
ExtensionOutlet uses no mandatory DOM wrapper, derives frozen placement from the
final Core order, preserves retained membership state across reorder, and confines each
contribution behind its own policy-controlled boundary. The lazyPanel
descriptor starts its loader only after a committed client passive effect. One retry
generation owns at most one loader attempt and AbortController; fulfillment renders
with the latest logical placement, while loader failures use the same contribution
policy and retry pipeline as render failures. Lifecycle loss always removes UI and
callback authority before dispatching an idempotent abort. A later loader settlement
cannot change the UI; while its original outlet and scope epoch are still current, it
may emit one safe lateLazyResult diagnostic without the value or rejection cause.
A current rejection named AbortError remains a normal REACT_LAZY_LOAD_FAILED
outcome. The host still owns any data fetching or module-loader strategy used inside
load().
In React 19, placing a provider subtree inside <Activity> closes adapter-owned
subscriptions and pending lazy work while hidden without discarding React membership
state. Reveal catches up Core changes before the first visible view, preserves ready
components and failed presentation, and restarts only a still-pending lazy attempt
with a new abort signal. React 18 keeps the same Strict Mode lifecycle without an
Activity-specific API requirement.
Server rendering
Traditional renderToString() uses the same public provider and outlet. Create one
mutable Core registry per request, register the shared immutable point contracts,
await every contribution that the response requires, and dispose the registry in the
request cleanup path:
import { renderToString } from "react-dom/server";
async function renderRequest(locale: string): Promise<string> {
const identifierPrefix = "request-a-";
const requestRegistry = createExtensionRegistry();
requestRegistry.rootScope.registerPoint(panels);
const requestPanel = requestRegistry.registerContribution(panels, {
contributionId: "request.panel",
expectedContractVersion: 1,
implementation: lazyPanel,
data: { title: "Overview" },
});
try {
await requestPanel.whenActive();
return renderToString(
<ExtensionRegistryProvider registry={requestRegistry}>
<ExtensionOutlet
point={panels}
context={{ locale }}
renderLoading={({ contribution, placement }) => (
<span data-position={`${placement.index}/${placement.count}`}>
Loading {contribution.data.title}…
</span>
)}
/>
</ExtensionRegistryProvider>,
{ identifierPrefix },
);
} finally {
await requestRegistry.dispose();
}
}Server rendering synchronously reads the already committed Core view. Eager
contributions render normally; managed-lazy contributions render their deterministic
loading state without starting the loader or creating an AbortController. No
adapter subscriber, effect lease, sink callback, or error policy runs on the server.
Descendant render failures pass through to the host's server error handling.
Hydration
Before hydrateRoot(), reconstruct the same committed logical view in a new
client-owned Core registry. Point and contribution IDs, contract versions, data,
context, order, selection result, eager/lazy kind, and render props must match the
server response. Runtime registration IDs do not need to match and must not be
serialized into HTML or bootstrap data.
import { hydrateRoot } from "react-dom/client";
const clientRegistry = createExtensionRegistry();
clientRegistry.rootScope.registerPoint(panels);
const clientPanel = clientRegistry.registerContribution(panels, {
contributionId: "request.panel",
expectedContractVersion: 1,
implementation: lazyPanel,
data: { title: "Overview" },
});
await clientPanel.whenActive();
const clientRoot = hydrateRoot(
document.querySelector("#app")!,
<ExtensionRegistryProvider registry={clientRegistry}>
<ExtensionOutlet
point={panels}
context={{ locale: "en" }}
renderLoading={({ contribution, placement }) => (
<span data-position={`${placement.index}/${placement.count}`}>
Loading {contribution.data.title}…
</span>
)}
/>
</ExtensionRegistryProvider>,
{ identifierPrefix: "request-a-" },
);
async function disposeClient(): Promise<void> {
clientRoot.unmount();
await clientRegistry.dispose();
}The identifierPrefix must exactly match the corresponding server
renderToString() call; simultaneous roots need distinct prefixes. The first
hydration render uses the same logical snapshot and loading placement as the server.
A managed-lazy loader starts only after the client tree commits. If setup or hydration
fails before the host transfers ownership to its normal lifecycle, attempt React root
unmount first and Core registry disposal second, preserving the primary error and all
cleanup failures.
Fast Refresh and Core HMR
React component refresh and Core point refresh are two independent development lifecycles. Keep a component-only React Refresh boundary separate from point definition and registration orchestration. A valid component-only edit can preserve eligible local React state without committing Core state, re-registering the contribution, or changing its activation identity.
For a point definition edit, pass the real bundler hot context and a stable hotKey
through defineReactExtensionPoint():
export const panels = defineReactExtensionPoint<PanelContext, PanelData>()({
pointId: "app.panels",
contractVersion: 1,
cardinality: "many",
filter(panel, context) {
return panel.data.area === context.area;
},
}, {
hot: import.meta.hot,
hotKey: "app.panels",
});
import.meta.hot?.accept();A compatible Core refresh keeps the canonical point and active registration IDs,
emits pointDefinitionRefreshed, and lets exact React consumers resolve the new
filter, ordering, or selection result atomically. An incompatible point contract is
rejected by Core with INCOMPATIBLE_HOT_UPDATE and delegates to the host invalidation
path without publishing a partial adapter view.
React Refresh state preservation remains a development-time best effort. Changing a module's non-component exports can invalidate its refresh boundary and legitimately produce a full reload or importer invalidation. Changing a managed-lazy loader or its descriptor is likewise not an adapter Fast Refresh contract: use an explicit Core contribution replacement or re-registration when the loader identity must change.
Workspace examples
- The readable React SPA combines the provider, resolved hook, headless outlet, Core ordering and selection, logical placement, isolated failures, retry, managed lazy loading, Activity hide/reveal, and component-only Fast Refresh.
- The React SSR and hydration example demonstrates
request-local registries, activation readiness,
renderToString(), matchinghydrateRoot()ownership, post-commit lazy loading, and root-before-registry cleanup.
These applications provide readable development evidence. The exact-version documentation and isolated compatibility fixtures cover the package and release boundaries independently.
License
ISC
