Designing PCF Controls for Three Deployment Profiles: Permissions, Performance, Volume
Most PCF controls only need to satisfy one deployment profile. Reusable controls have to handle three. How config-driven design, privilege fallback, and pagination keep one ZIP working across very different environments.
A PCF that only ever runs in your dev tenant has one job. A PCF that ships to other organizations has at least three.
When I started building Field Audit History, it was a single-tenant tool. By the time it became a config-driven control with a public repo, the design had to absorb three very different deployment profiles. Same managed solution. Same TypeScript source. Different reality on the ground in each environment.
This article is about those three profiles. Not three named clients. Three classes of environment that any reusable PCF will eventually meet, and the design moves that let one codebase serve all of them.
Why Profiles, Not Environments
A “test in three orgs” article would be more colourful. It would also be misleading. The interesting variation is not which company runs the control. The interesting variation is what the deployment looks like along three axes:
- Permission posture. How tightly the org has locked down the audit-related Dataverse privileges, and whether end users have them.
- Data volume. How many audit entries a typical record actually has.
- Configurability tolerance. Whether the org wants the default behavior or wants to tune it per entity.
Real environments cluster on these axes. Below are the three clusters worth designing for.
Profile A: Standard Permissions, Modest Volume, Minimal Configuration
The most common profile. A reasonably sized org with default-ish security roles, a handful of audited entities, and audit history per record measured in tens of entries, not thousands. The admin imports the solution, binds the control to a host field, and it works on first open.
The customization that does come up sounds like this: “we want the control on Account, Contact, Opportunity, and our custom Project entity, with these specific fields.” If your control hardcodes table or field names, this turns into a code change, a rebuild, and a new managed solution import. If it does not, the same request becomes an edit to a JSON web resource.
The shape that survives this profile is a IConfig interface plus a loader. In Field Audit History the JSON looks roughly like this:
var config = {
tables: {
"account": {
mode: "include",
fields: ["emailaddress1", "telephone1", "revenue",
"numberofemployees", "ownerid"]
},
"vp365_project": {
mode: "audited",
fields: []
}
}
};
mode: "include" means use the explicit field list. mode: "audited" means use whatever the Dataverse IsAuditEnabled metadata says for that entity. The PCF reads this once during init() through a hook (in the public repo, useAuditConfig), merges with defaults, and never touches the web resource again that session.
The payoff is small in absolute terms and large in cumulative terms. A new entity goes from a dev round trip to a JSON paste. A new field on an existing entity goes from the same round trip to adding a string to an array.
This profile is the easy case. The interesting design work is in the next two.
Profile B: Restricted Permissions, Mature Security Model
In this profile, end users do not have the Dataverse privileges the control needs. Audit history reads require prvReadRecordAuditHistory (per-record audit) and frequently prvReadAuditSummary as well. In any org with a serious compliance posture, those privileges are scoped to a small group, often only a compliance or security team. Everyone else has the host fields visible but no audit data behind them.
Two failure modes are common when a control is not designed for this profile:
- Silent failure. The user clicks the audit icon, the API returns 403, the control swallows it, nothing happens. The user assumes the control is broken.
- Loud failure. The user clicks, sees a stack trace or a console-error toast, and files a ticket.
Neither is acceptable. The third option, and the one Field Audit History adopted, is to make the privilege state a first-class branch in the control’s state machine.
Detect, Do Not Discover
The first move is checking privileges during init() rather than discovering the lack of them on the first failed API call. The Dataverse Web API exposes user privileges through RetrieveMyPrivileges. Query it once on init, cache the answer, and let the rest of the control read the cached state.
private async checkAuditPrivileges(): Promise<IAuditPrivileges> {
try {
const userPrivileges = await this.getUserPrivileges();
return {
canReadAuditHistory: userPrivileges.has("prvReadRecordAuditHistory"),
canReadAuditSummary: userPrivileges.has("prvReadAuditSummary"),
};
} catch {
// Distinguish "denied" from "check failed"
return { canReadAuditHistory: false, canReadAuditSummary: false, checkFailed: true };
}
}
The third state, checkFailed, matters. A network timeout on the privilege query is not the same as a confirmed denial. Treating them the same gives users with valid privileges a “you do not have access” message whenever the API is slow.
Design the Fallback as a Real State
Once the privilege state is known, the control needs four UI states, not the two it shipped with originally:
- Loading. The privilege check or the audit data is in flight.
- Data available. Render the audit history.
- No data. Field has never been changed. Show an empty state, not an error.
- No permission. Render a configurable fallback.
The fallback section lives in the same IConfig shape as everything else:
var config = {
fallback: {
enabled: true,
title: "Audit data has restricted access",
message: "Audit history for this field is managed by the security team.",
linkText: "Request audit data access",
linkUrl: "https://example.internal/audit-request"
}
};
When fallback.enabled is true and the user lacks the relevant privilege, clicking an icon opens a panel with the configured message and link. When fallback.enabled is missing or false, the control falls back to its default “no permission” message. Existing deployments are unaffected.
Render the Restricted State Visibly
A subtle but important UI decision: the audit icons still render for users without privileges. They render in a muted state, with a different tooltip, and clicking them opens the fallback panel rather than a data popup. Hiding the icons entirely confuses users who were told the feature exists. Showing them in the normal state leads to a dead click. The muted state is the third option.
private renderAuditIcon(fieldName: string): HTMLElement {
const icon = document.createElement("span");
icon.classList.add("vp365-audit-icon");
if (!this._auditPrivileges.canReadAuditHistory) {
icon.classList.add("vp365-audit-icon--restricted");
icon.title = this._config.fallback?.title ?? "Restricted";
}
icon.addEventListener("click", () => this.handleIconClick(fieldName));
return icon;
}
The pattern that makes this design work is the one from Profile A: configuration is the unit of customization, not code. A control that ships with a hardcoded “you do not have permission” string and no fallback hook becomes a fork the moment a security-sensitive deployment shows up.
Profile C: High Volume, Long-Lived Records
In this profile, the org is not unusually security-strict. The challenge is data. Custom entities with dozens of audited fields. Records that have been edited hundreds or thousands of times over a multi-year history. A response from the audit endpoint that comes back as a megabyte of JSON for a single record.
Field Audit History v1 fetched all audit entries for a record in one call. For Profile A this was fine. For Profile C the same code produced a multi-second click-to-popup delay on records with deep histories, and a longer delay when the user opened the full timeline panel.
The fix is three coordinated changes: server-side pagination, lazy DOM rendering, and request cancellation.
Server-Side Pagination
The audit endpoint supports $top and $skip. The original code did not use them, reasoning vaguely that the user might want to scroll all the way back. Most users do not scroll past the most recent dozen entries.
// Before: fetch everything
const result = await context.webAPI.retrieveMultipleRecords(
"audit",
`?$filter=_objectid_value eq '${recordId}'&$orderby=createdon desc`
);
// After: paginated fetch driven by config
const pageSize = this._config.pagination?.pageSize ?? 50;
const result = await context.webAPI.retrieveMultipleRecords(
"audit",
`?$filter=_objectid_value eq '${recordId}'` +
`&$orderby=createdon desc` +
`&$top=${pageSize}`
);
The pageSize is a config option. Profile C deployments tune it down on slow networks or up when they want fewer round trips.
Lazy Rendering with IntersectionObserver
The full timeline panel originally rendered every field group and every entry on open. With dozens of audited fields and hundreds of entries each, that was thousands of DOM nodes created up front.
Switching to an IntersectionObserver model means the panel renders an initial batch of field groups (default 10), each collapsed to its most recent few entries, and a sentinel element below them. Scrolling brings the sentinel into view, the observer fires, and the next batch appends.
private setupIntersectionObserver(groups: IFieldGroup[], startIndex: number): void {
const sentinel = document.createElement("div");
sentinel.classList.add("vp365-sentinel");
this._panelContainer.appendChild(sentinel);
const observer = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting) return;
const batchSize = this._config.pagination?.fieldGroupBatchSize ?? 10;
const nextBatch = groups.slice(startIndex, startIndex + batchSize);
nextBatch.forEach(group => {
this._panelContainer.insertBefore(
this.renderFieldGroup(group, { collapsed: true }),
sentinel
);
});
startIndex += nextBatch.length;
if (startIndex >= groups.length) {
observer.disconnect();
sentinel.remove();
}
});
observer.observe(sentinel);
}
The constants are config-driven. Defaults are tuned for the common case. High-volume deployments override them.
Cancellable Fetches with AbortController
Users on dense forms click multiple icons in quick succession. Without cancellation, every click is an in-flight request, and a fast user with a slow network can pile up several large responses worth of work that all complete around the same time and fight for state updates.
AbortController is built into every supported browser. Wire it into the click handler so a new click cancels the previous fetch:
private _auditAbortController: AbortController | null = null;
private async handleIconClick(fieldName: string): Promise<void> {
this._auditAbortController?.abort();
this._auditAbortController = new AbortController();
try {
const data = await this.fetchAuditData(
fieldName,
this._auditAbortController.signal
);
this.renderQuickPeek(fieldName, data);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
this.renderError(fieldName, err);
}
}
What Lives in Config
The performance shape ends up entirely config-driven:
interface IPaginationConfig {
pageSize?: number; // default 50
fieldGroupBatchSize?: number; // default 10
quickPeekEntryCount?: number; // default 8
}
Profile A deployments accept the defaults. Profile C deployments tune. Same ZIP.
What the Three Profiles Have in Common
Look at what survives across all three:
- The PCF source code. No per-deployment branches.
- The managed solution package. One ZIP everywhere.
- The deployment process. Import, bind, configure.
What changes per deployment:
- The JSON web resource. The shape of
IConfigis the contract. - The feature surface. Fallback on or off. Pagination tuned or not.
- The UI states the user actually encounters.
The control adapts to the profile through configuration. Not through code. Not through forks. The architectural moves that make this possible are not exotic. They are the same moves that make any reusable software reusable: a typed configuration shape, a state machine that handles failure as a first-class state, and runtime knobs for the things that vary.
Design Heuristics for Reusable PCFs
A short list, drawn from the patterns above:
- 1
Externalize the schema, not just the values
A typed IConfig with a default-merge step is the move. Strings in a manifest property are not enough. The configuration is part of the public contract of the control. Treat it like one.
- 2
Privilege check during init, not on first failure
RetrieveMyPrivileges is cheap. Run it once. Cache it. Let the rest of the control read the cached state. Distinguish 'denied' from 'check failed' so users on slow networks do not see denial messages they did not earn.
- 3
Four states minimum: loading, data, empty, no-permission
Most v1 PCFs ship with two states (data or error). Reusable controls need at least four. Add the fallback state to the state machine on day one, even if you do not yet style it well.
- 4
Pagination from v1, fetch-all as opt-in
Defaults that work for hundreds of entries also work for tens. Defaults that work for tens fall over at thousands. Pick the cautious default and let high-volume deployments scale up if they need to.
- 5
AbortController on every user-driven fetch
A few lines of plumbing buys back a class of bugs. Wire it in early.
- 6
Tune through config, not code
Anything that varies between deployments belongs in IConfig. Page sizes, batch sizes, fallback messages, link URLs. The boundary between 'one ZIP, many configs' and 'one fork per client' is whether the variable thing is in TypeScript or in a JSON web resource.
What I Would Do Differently Next Time
Two notes for the next reusable PCF I build, separate from any specific deployment story:
Privilege check before config load. The current Field Audit History flow loads the config first and then checks privileges. The flow that actually wastes the least work is the inverse: know what the user can do, then decide what to load. Negligible difference for most users. Real difference in restricted environments where the config load is itself an unnecessary round trip.
A diagnostics flag in IConfig. A small boolean that, when true, logs entry counts, response sizes, and render times to the console. Easier to ship than a full telemetry pipeline, sufficient for diagnosing a slow open in any environment, and harmless when off.
The Unit of Reuse
The temptation in v1 is to hardcode. You know the entity name. You know the field list. You know the rough data volume. Hardcode it, ship it, move on.
Then a second use case shows up. Then a third, with stricter security. Then a fourth, with data volumes you did not plan for. By the time the fork tax has compounded over a year, the control that was supposed to make your life easier is the one that takes the most maintenance.
The unit of reuse for a PCF is not the TypeScript file. It is the pair: the control plus the config shape it consumes. Get the config shape right and the same source serves environments you have not seen yet. Get it wrong and you pay for it on every deployment.
VP365.ai is Power Platform tools for practitioners who ship. Follow Victoria on LinkedIn for new controls and deeper PCF deep dives.
Stay in the loop
Get new posts delivered to your inbox. No spam, unsubscribe anytime.
Related articles
Config-Driven PCF Components - Stop Hardcoding Table Names
How to build PCF controls that work on any Dataverse entity without rebuilding. The JSON web resource pattern with real examples from two production controls.
How to Build Your First PCF Control in 2026
Step-by-step guide to building, testing, and deploying a PCF control with modern tooling. From pac pcf init to a working control on a D365 form.
I Built a PCF Control with AI - Here's Every Prompt I Used
The exact AI prompts, failures, and iterations behind a production PCF control with 90+ unit tests and config-driven architecture.