fe-lwc-skill 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/bin/index.js +145 -0
- package/package.json +23 -0
- package/skill/SKILL.md +88 -0
- package/skill/rules/data-error-handling.md +297 -0
- package/skill/rules/data-lds-first.md +261 -0
- package/skill/rules/data-wire-vs-imperative.md +248 -0
- package/skill/rules/design-token-dxp.md +230 -0
- package/skill/rules/design-token-slds.md +258 -0
- package/skill/rules/events-custom-event.md +227 -0
- package/skill/rules/events-lms.md +193 -0
- package/skill/rules/template-directives.md +191 -0
- package/skill/rules/template-dom-querying.md +202 -0
- package/skill/rules/template-form-pattern.md +285 -0
- package/skill/rules/template-track-immutable.md +206 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# events-custom-event
|
|
2
|
+
|
|
3
|
+
**Impact: HIGH**
|
|
4
|
+
|
|
5
|
+
Custom events are the standard mechanism for child-to-parent communication in LWC. Two boolean flags — `bubbles` and `composed` — control how far an event travels through the DOM.
|
|
6
|
+
|
|
7
|
+
> **Rule:**
|
|
8
|
+
>
|
|
9
|
+
> - Always declare `bubbles` and `composed` explicitly.
|
|
10
|
+
> - Prefer **kebab-case** event names (`step-completed`) — camelCase is valid but less portable across some platforms.
|
|
11
|
+
> - The template handler attribute is always `on` + the full event name: `onstep-completed` or `onstepcompleted`.
|
|
12
|
+
> - Pass data through `detail`, not as top-level properties on the event object.
|
|
13
|
+
> - Keep `composed: true` only when the listener lives outside the component's shadow root.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Decision Guide: `bubbles` and `composed`
|
|
18
|
+
|
|
19
|
+
| Listener location | `bubbles` | `composed` |
|
|
20
|
+
| ---------------------------------------------------------- | ------------------- | ---------- |
|
|
21
|
+
| Direct parent only | `false` | `false` |
|
|
22
|
+
| Any ancestor in the same shadow tree | `true` | `false` |
|
|
23
|
+
| Outside the shadow root (cross-shadow, App Builder region) | `true` | `true` |
|
|
24
|
+
| Cross-region on Experience Cloud | Use **LMS** instead | — |
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Case 1: Missing flags, data not in `detail`
|
|
29
|
+
|
|
30
|
+
**Incorrect:**
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
// appointmentCard.ts
|
|
34
|
+
import { LightningElement, api } from "lwc";
|
|
35
|
+
|
|
36
|
+
// @ts-ignore
|
|
37
|
+
export default class AppointmentCard extends LightningElement {
|
|
38
|
+
// @ts-ignore
|
|
39
|
+
@api recordId: string;
|
|
40
|
+
|
|
41
|
+
public handleCancel(): void {
|
|
42
|
+
// ❌ No bubbles/composed, data not in detail
|
|
43
|
+
const event = new CustomEvent("appointmentCancelled", {
|
|
44
|
+
appointmentId: this.recordId,
|
|
45
|
+
reason: "User cancelled",
|
|
46
|
+
} as CustomEventInit);
|
|
47
|
+
this.dispatchEvent(event);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Correct — explicit flags, data in `detail`:**
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
// appointmentCard.ts
|
|
56
|
+
import { LightningElement, api } from "lwc";
|
|
57
|
+
|
|
58
|
+
interface AppointmentCancelledDetail {
|
|
59
|
+
appointmentId: string;
|
|
60
|
+
reason: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// @ts-ignore
|
|
64
|
+
export default class AppointmentCard extends LightningElement {
|
|
65
|
+
// @ts-ignore
|
|
66
|
+
@api recordId: string;
|
|
67
|
+
|
|
68
|
+
public handleCancel(): void {
|
|
69
|
+
this.dispatchEvent(
|
|
70
|
+
new CustomEvent<AppointmentCancelledDetail>("appointmentCancelled", {
|
|
71
|
+
bubbles: false, // ✅ explicit — direct parent is the listener
|
|
72
|
+
composed: false, // ✅ explicit — same shadow root
|
|
73
|
+
detail: {
|
|
74
|
+
appointmentId: this.recordId,
|
|
75
|
+
reason: "User cancelled",
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// parentContainer.ts
|
|
85
|
+
import { LightningElement } from "lwc";
|
|
86
|
+
|
|
87
|
+
interface AppointmentCancelledDetail {
|
|
88
|
+
appointmentId: string;
|
|
89
|
+
reason: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// @ts-ignore
|
|
93
|
+
export default class ParentContainer extends LightningElement {
|
|
94
|
+
public handleCancel(event: CustomEvent<AppointmentCancelledDetail>): void {
|
|
95
|
+
const { appointmentId, reason } = event.detail;
|
|
96
|
+
// handle cancellation...
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```html
|
|
102
|
+
<!-- parentContainer.html -->
|
|
103
|
+
<c-appointment-card onappointmentcancelled="{handleCancel}"></c-appointment-card>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Case 2: Event must bubble through multiple ancestors
|
|
109
|
+
|
|
110
|
+
Scenario: `appointmentStep` → `appointmentWizard` → `appointmentModal`. The modal is the listener.
|
|
111
|
+
|
|
112
|
+
**Incorrect — `bubbles: false` stops the event at the direct parent:**
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// appointmentStep.ts
|
|
116
|
+
handleNext(): void {
|
|
117
|
+
this.dispatchEvent(
|
|
118
|
+
new CustomEvent('step-completed', {
|
|
119
|
+
bubbles: false, // ❌ stops at appointmentWizard
|
|
120
|
+
composed: false,
|
|
121
|
+
detail: { stepIndex: this.currentStep },
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Correct:**
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// appointmentStep.ts
|
|
131
|
+
import { LightningElement } from "lwc";
|
|
132
|
+
|
|
133
|
+
interface StepCompletedDetail {
|
|
134
|
+
stepIndex: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// @ts-ignore
|
|
138
|
+
export default class AppointmentStep extends LightningElement {
|
|
139
|
+
currentStep: number = 0;
|
|
140
|
+
|
|
141
|
+
public handleNext(): void {
|
|
142
|
+
this.dispatchEvent(
|
|
143
|
+
new CustomEvent<StepCompletedDetail>("step-completed", {
|
|
144
|
+
bubbles: true, // ✅ travels up to appointmentModal
|
|
145
|
+
composed: false, // ✅ still within the same shadow root
|
|
146
|
+
detail: { stepIndex: this.currentStep },
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
// appointmentModal.ts
|
|
155
|
+
import { LightningElement } from "lwc";
|
|
156
|
+
|
|
157
|
+
// @ts-ignore
|
|
158
|
+
export default class AppointmentModal extends LightningElement {
|
|
159
|
+
currentStep: number = 0;
|
|
160
|
+
|
|
161
|
+
public handleStepCompleted(event: CustomEvent<{ stepIndex: number }>): void {
|
|
162
|
+
this.currentStep = event.detail.stepIndex + 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Case 3: `composed: true` when crossing shadow roots (slotted content)
|
|
170
|
+
|
|
171
|
+
**Incorrect:**
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
// slottedForm.ts
|
|
175
|
+
handleSubmit(): void {
|
|
176
|
+
this.dispatchEvent(
|
|
177
|
+
new CustomEvent('form-submitted', {
|
|
178
|
+
bubbles: true,
|
|
179
|
+
composed: false, // ❌ cannot cross the shadow root boundary
|
|
180
|
+
detail: { formData: this.formState },
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Correct:**
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
// slottedForm.ts
|
|
190
|
+
import { LightningElement } from "lwc";
|
|
191
|
+
|
|
192
|
+
interface FormSubmittedDetail {
|
|
193
|
+
formData: Record<string, string>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// @ts-ignore
|
|
197
|
+
export default class SlottedForm extends LightningElement {
|
|
198
|
+
formState: Record<string, string> = {};
|
|
199
|
+
|
|
200
|
+
public handleSubmit(): void {
|
|
201
|
+
this.dispatchEvent(
|
|
202
|
+
new CustomEvent<FormSubmittedDetail>("form-submitted", {
|
|
203
|
+
bubbles: true,
|
|
204
|
+
composed: true, // ✅ crosses the shadow boundary to the slot host
|
|
205
|
+
detail: { formData: this.formState },
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Quick Decision Guide
|
|
215
|
+
|
|
216
|
+
| Situation | Pattern |
|
|
217
|
+
| -------------------------------------------------- | -------------------------------------------------------------- |
|
|
218
|
+
| Child notifies direct parent | `bubbles: false`, `composed: false` |
|
|
219
|
+
| Child notifies distant ancestor (same shadow tree) | `bubbles: true`, `composed: false` |
|
|
220
|
+
| Slotted component notifies slot host | `bubbles: true`, `composed: true` |
|
|
221
|
+
| Cross-region on App Builder / Experience Cloud | Use **LMS** |
|
|
222
|
+
| Event name | Prefer kebab-case; handler is `on` + name, all lowercase |
|
|
223
|
+
| Payload | Always in **`detail`** — typed with a generic `CustomEvent<T>` |
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
**Reference:** [Create and Dispatch Events — Salesforce LWC Developer Guide](https://developer.salesforce.com/docs/platform/lwc/guide/events-create-dispatch.html)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# events-lms
|
|
2
|
+
|
|
3
|
+
**Impact: HIGH**
|
|
4
|
+
|
|
5
|
+
Lightning Message Service (LMS) is the platform-native solution for communication between components that have no parent-child relationship, or that live in different shadow roots across App Builder regions and Experience Cloud portal slots.
|
|
6
|
+
|
|
7
|
+
> **Rule:**
|
|
8
|
+
>
|
|
9
|
+
> - Use LMS when components have **no shared parent**, or live in **different App Builder regions / Experience Cloud slots**.
|
|
10
|
+
> - Always **unsubscribe** in `disconnectedCallback` to prevent memory leaks.
|
|
11
|
+
> - Define each message channel in a dedicated `.messageChannel` metadata file.
|
|
12
|
+
|
|
13
|
+
> **Note for Jest tests:** `@salesforce/messageChannel` requires a mock in Jest. Add the mock in `jest.config.js`:
|
|
14
|
+
>
|
|
15
|
+
> ```javascript
|
|
16
|
+
> moduleNameMapper: {
|
|
17
|
+
> '@salesforce/messageChannel/(.+)': '<rootDir>/path/to/__mocks__/messageChannel.js'
|
|
18
|
+
> }
|
|
19
|
+
> ```
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Decision Guide: LMS vs Custom Event
|
|
24
|
+
|
|
25
|
+
| Scenario | Use |
|
|
26
|
+
| --------------------------------------------------------- | -------------------------------------------------------- |
|
|
27
|
+
| Child notifies direct parent | Custom event (`bubbles: false`) |
|
|
28
|
+
| Child notifies distant ancestor (same shadow tree) | Custom event (`bubbles: true`) |
|
|
29
|
+
| Component in Experience Cloud header notifies main region | **LMS** |
|
|
30
|
+
| Two sibling components with no shared parent | **LMS** |
|
|
31
|
+
| Cross-tab or cross-page communication | Not supported — use server-side state or Platform Events |
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Step 1: Define the MessageChannel metadata file
|
|
36
|
+
|
|
37
|
+
```xml
|
|
38
|
+
<!-- force-app/main/default/messageChannels/AppointmentSelected.messageChannel-meta.xml -->
|
|
39
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
40
|
+
<LightningMessageChannel xmlns="http://soap.sforce.com/2006/04/metadata">
|
|
41
|
+
<masterLabel>AppointmentSelected</masterLabel>
|
|
42
|
+
<isExposed>true</isExposed>
|
|
43
|
+
<description>Notifies subscribers when a patient appointment is selected.</description>
|
|
44
|
+
<lightningMessageFields>
|
|
45
|
+
<fieldName>appointmentId</fieldName>
|
|
46
|
+
<description>Salesforce record Id of the selected appointment.</description>
|
|
47
|
+
</lightningMessageFields>
|
|
48
|
+
<lightningMessageFields>
|
|
49
|
+
<fieldName>patientName</fieldName>
|
|
50
|
+
<description>Display name of the patient.</description>
|
|
51
|
+
</lightningMessageFields>
|
|
52
|
+
</LightningMessageChannel>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Step 2: Publisher
|
|
58
|
+
|
|
59
|
+
**Incorrect — custom event cannot reach a component in a different region:**
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// appointmentList.ts
|
|
63
|
+
handleAppointmentClick(event: Event): void {
|
|
64
|
+
const target = event.currentTarget as HTMLElement;
|
|
65
|
+
this.dispatchEvent(
|
|
66
|
+
new CustomEvent('appointmentselected', {
|
|
67
|
+
bubbles: true,
|
|
68
|
+
composed: true, // ❌ still won't reach a separate region/slot
|
|
69
|
+
detail: { appointmentId: target.dataset.id },
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Correct — publish via LMS:**
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// appointmentList.ts
|
|
79
|
+
import { LightningElement, wire } from "lwc";
|
|
80
|
+
import { MessageContext, publish, MessageContextType } from "lightning/messageService";
|
|
81
|
+
import APPOINTMENT_SELECTED from "@salesforce/messageChannel/AppointmentSelected__c";
|
|
82
|
+
|
|
83
|
+
interface AppointmentSelectedPayload {
|
|
84
|
+
appointmentId: string;
|
|
85
|
+
patientName: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// @ts-ignore
|
|
89
|
+
export default class AppointmentList extends LightningElement {
|
|
90
|
+
// @ts-ignore
|
|
91
|
+
@wire(MessageContext)
|
|
92
|
+
messageContext: MessageContextType;
|
|
93
|
+
|
|
94
|
+
public handleAppointmentClick(event: Event): void {
|
|
95
|
+
const target = event.currentTarget as HTMLElement;
|
|
96
|
+
|
|
97
|
+
const payload: AppointmentSelectedPayload = {
|
|
98
|
+
appointmentId: target.dataset.id ?? "",
|
|
99
|
+
patientName: target.dataset.patientName ?? "",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
publish(this.messageContext, APPOINTMENT_SELECTED, payload);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Step 3: Subscriber
|
|
110
|
+
|
|
111
|
+
**Incorrect — subscribing without unsubscribing:**
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
// appointmentDetail.ts
|
|
115
|
+
import { LightningElement, wire } from "lwc";
|
|
116
|
+
import { MessageContext, subscribe, MessageContextType } from "lightning/messageService";
|
|
117
|
+
import APPOINTMENT_SELECTED from "@salesforce/messageChannel/AppointmentSelected__c";
|
|
118
|
+
|
|
119
|
+
// @ts-ignore
|
|
120
|
+
export default class AppointmentDetail extends LightningElement {
|
|
121
|
+
// @ts-ignore
|
|
122
|
+
@wire(MessageContext)
|
|
123
|
+
messageContext: MessageContextType;
|
|
124
|
+
|
|
125
|
+
public connectedCallback(): void {
|
|
126
|
+
subscribe(this.messageContext, APPOINTMENT_SELECTED, (message) => {
|
|
127
|
+
this.appointmentId = (message as { appointmentId: string }).appointmentId;
|
|
128
|
+
});
|
|
129
|
+
// ❌ return value discarded — cannot unsubscribe later
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Correct — store the subscription handle and unsubscribe in `disconnectedCallback`:**
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// appointmentDetail.ts
|
|
138
|
+
import { LightningElement, wire } from "lwc";
|
|
139
|
+
import {
|
|
140
|
+
MessageContext,
|
|
141
|
+
subscribe,
|
|
142
|
+
unsubscribe,
|
|
143
|
+
MessageContextType,
|
|
144
|
+
MessageServiceSubscription,
|
|
145
|
+
} from "lightning/messageService";
|
|
146
|
+
import APPOINTMENT_SELECTED from "@salesforce/messageChannel/AppointmentSelected__c";
|
|
147
|
+
|
|
148
|
+
interface AppointmentSelectedPayload {
|
|
149
|
+
appointmentId: string;
|
|
150
|
+
patientName: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// @ts-ignore
|
|
154
|
+
export default class AppointmentDetail extends LightningElement {
|
|
155
|
+
// @ts-ignore
|
|
156
|
+
@wire(MessageContext)
|
|
157
|
+
messageContext: MessageContextType;
|
|
158
|
+
|
|
159
|
+
subscription: MessageServiceSubscription | null = null;
|
|
160
|
+
appointmentId: string | null = null;
|
|
161
|
+
|
|
162
|
+
public connectedCallback(): void {
|
|
163
|
+
this.subscription = subscribe(this.messageContext, APPOINTMENT_SELECTED, (message) =>
|
|
164
|
+
this.handleMessage(message as AppointmentSelectedPayload),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public disconnectedCallback(): void {
|
|
169
|
+
unsubscribe(this.subscription); // ✅ clean up when component leaves the DOM
|
|
170
|
+
this.subscription = null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private handleMessage(message: AppointmentSelectedPayload): void {
|
|
174
|
+
this.appointmentId = message.appointmentId;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Quick Decision Guide
|
|
182
|
+
|
|
183
|
+
| Situation | Pattern |
|
|
184
|
+
| ---------------------------------------- | ----------------------------------------------------- |
|
|
185
|
+
| Same shadow tree, parent-child | Custom event |
|
|
186
|
+
| Different regions / unrelated components | LMS |
|
|
187
|
+
| Payload type | Typed interface + cast on receive |
|
|
188
|
+
| Unsubscribe | Always in `disconnectedCallback` |
|
|
189
|
+
| Jest tests | Mock `@salesforce/messageChannel` in `jest.config.js` |
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
**Reference:** [Lightning Message Service — Salesforce LWC Developer Guide](https://developer.salesforce.com/docs/platform/lwc/guide/use-message-service.html)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# template-directives
|
|
2
|
+
|
|
3
|
+
**Impact: HIGH**
|
|
4
|
+
|
|
5
|
+
LWC introduced modern template directives in API version 58.0. The old `if:true` / `if:false` directives are deprecated. Always use `lwc:if` / `lwc:elseif` / `lwc:else` for conditionals and `for:each` with a `key` for loops.
|
|
6
|
+
|
|
7
|
+
> **Rule:**
|
|
8
|
+
>
|
|
9
|
+
> - Never use `if:true` or `if:false` — use `lwc:if` / `lwc:elseif` / `lwc:else`.
|
|
10
|
+
> - `lwc:elseif` and `lwc:else` must be **direct siblings** of `lwc:if` — no elements between them.
|
|
11
|
+
> - Always use `for:each` with a unique `key` on the **direct child element** — never on the `<template>` tag.
|
|
12
|
+
> - Never use array index as `key` — use a stable unique ID from the data.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Case 1: Conditional rendering
|
|
17
|
+
|
|
18
|
+
**Incorrect — deprecated directives:**
|
|
19
|
+
|
|
20
|
+
```html
|
|
21
|
+
<template>
|
|
22
|
+
<template if:true="{isLoading}">
|
|
23
|
+
<lightning-spinner></lightning-spinner>
|
|
24
|
+
</template>
|
|
25
|
+
<template if:false="{isLoading}">
|
|
26
|
+
<template if:true="{hasData}">
|
|
27
|
+
<p>{appointment.Name}</p>
|
|
28
|
+
</template>
|
|
29
|
+
</template>
|
|
30
|
+
</template>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Correct — modern directives:**
|
|
34
|
+
|
|
35
|
+
```html
|
|
36
|
+
<template>
|
|
37
|
+
<template lwc:if="{isLoading}">
|
|
38
|
+
<lightning-spinner></lightning-spinner>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<template lwc:elseif="{hasData}">
|
|
42
|
+
<p>{appointment.Name}</p>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<template lwc:else>
|
|
46
|
+
<p>No appointment found.</p>
|
|
47
|
+
</template>
|
|
48
|
+
</template>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> ⚠️ **Sibling rule:** `lwc:elseif` / `lwc:else` must immediately follow `lwc:if` with no elements in between, or the chain breaks silently.
|
|
52
|
+
>
|
|
53
|
+
> ```html
|
|
54
|
+
> <!-- ❌ Breaks the chain — p element between if and elseif -->
|
|
55
|
+
> <template lwc:if="{condA}">...</template>
|
|
56
|
+
> <p>something</p>
|
|
57
|
+
> <template lwc:elseif="{condB}">...</template>
|
|
58
|
+
> ```
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// appointmentCard.ts
|
|
62
|
+
import { LightningElement, api, wire } from "lwc";
|
|
63
|
+
import { getRecord, getFieldValue } from "lightning/uiRecordApi";
|
|
64
|
+
import NAME_FIELD from "@salesforce/schema/Appointment__c.Name";
|
|
65
|
+
|
|
66
|
+
// @ts-ignore
|
|
67
|
+
export default class AppointmentCard extends LightningElement {
|
|
68
|
+
// @ts-ignore
|
|
69
|
+
@api recordId: string;
|
|
70
|
+
|
|
71
|
+
isLoading: boolean = true;
|
|
72
|
+
appointment: Record<string, unknown> | null = null;
|
|
73
|
+
|
|
74
|
+
// @ts-ignore
|
|
75
|
+
@wire(getRecord, { recordId: "$recordId", fields: [NAME_FIELD] })
|
|
76
|
+
public handleRecord({ data, error }: { data?: Record<string, unknown>; error?: unknown }): void {
|
|
77
|
+
this.isLoading = false;
|
|
78
|
+
if (data) {
|
|
79
|
+
this.appointment = data;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public get hasData(): boolean {
|
|
84
|
+
return !!this.appointment;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public get appointmentName(): string | null {
|
|
88
|
+
return getFieldValue(this.appointment, NAME_FIELD) as string | null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Case 2: Rendering a list with `for:each`
|
|
96
|
+
|
|
97
|
+
**Incorrect — `key` on `<template>` tag (ignored by LWC engine):**
|
|
98
|
+
|
|
99
|
+
```html
|
|
100
|
+
<!-- ❌ key on <template> is ignored -->
|
|
101
|
+
<template for:each="{appointments}" for:item="appt" key="{appt.Id}">
|
|
102
|
+
<div>
|
|
103
|
+
<p>{appt.Name}</p>
|
|
104
|
+
</div>
|
|
105
|
+
</template>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Incorrect — array index as key:**
|
|
109
|
+
|
|
110
|
+
```html
|
|
111
|
+
<!-- ❌ causes incorrect DOM reuse on reorder/remove -->
|
|
112
|
+
<template for:each="{appointments}" for:item="appt" for:index="idx">
|
|
113
|
+
<div key="{idx}">
|
|
114
|
+
<p>{appt.Name}</p>
|
|
115
|
+
</div>
|
|
116
|
+
</template>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Correct — `key` on the direct child element, using a stable record ID:**
|
|
120
|
+
|
|
121
|
+
```html
|
|
122
|
+
<template>
|
|
123
|
+
<template for:each="{appointments}" for:item="appt">
|
|
124
|
+
<div key="{appt.Id}">
|
|
125
|
+
<p>{appt.Name}</p>
|
|
126
|
+
<p>{appt.Status__c}</p>
|
|
127
|
+
</div>
|
|
128
|
+
</template>
|
|
129
|
+
</template>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
// appointmentList.ts
|
|
134
|
+
import { LightningElement, api, wire } from "lwc";
|
|
135
|
+
import getAppointments from "@salesforce/apex/AppointmentController.getAppointments";
|
|
136
|
+
|
|
137
|
+
interface AppointmentRecord {
|
|
138
|
+
Id: string;
|
|
139
|
+
Name: string;
|
|
140
|
+
Status__c: string;
|
|
141
|
+
IsUrgent__c: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// @ts-ignore
|
|
145
|
+
export default class AppointmentList extends LightningElement {
|
|
146
|
+
// @ts-ignore
|
|
147
|
+
@api recordId: string;
|
|
148
|
+
|
|
149
|
+
// @ts-ignore
|
|
150
|
+
@wire(getAppointments, { recordId: "$recordId" })
|
|
151
|
+
appointments: { data?: AppointmentRecord[]; error?: unknown };
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Case 3: Conditional rendering inside a loop
|
|
158
|
+
|
|
159
|
+
```html
|
|
160
|
+
<template>
|
|
161
|
+
<template for:each="{appointments.data}" for:item="appt">
|
|
162
|
+
<div key="{appt.Id}" class="slds-m-bottom_small">
|
|
163
|
+
<p>{appt.Name}</p>
|
|
164
|
+
|
|
165
|
+
<template lwc:if="{appt.IsUrgent__c}">
|
|
166
|
+
<lightning-badge label="Urgent" class="slds-theme_error"></lightning-badge>
|
|
167
|
+
</template>
|
|
168
|
+
|
|
169
|
+
<template lwc:else>
|
|
170
|
+
<lightning-badge label="Routine"></lightning-badge>
|
|
171
|
+
</template>
|
|
172
|
+
</div>
|
|
173
|
+
</template>
|
|
174
|
+
</template>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Quick Decision Guide
|
|
180
|
+
|
|
181
|
+
| Situation | Pattern |
|
|
182
|
+
| ------------------------------- | ----------------------------------------------------------- |
|
|
183
|
+
| Show/hide one block | `lwc:if` |
|
|
184
|
+
| if / else if / else | `lwc:if` + `lwc:elseif` + `lwc:else` (direct siblings only) |
|
|
185
|
+
| Render a list | `for:each` + `key` on direct child element |
|
|
186
|
+
| Key value | Stable record ID — never array index |
|
|
187
|
+
| Conditional content inside loop | `lwc:if` inside `for:each` |
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
**Reference:** [Render Lists — Salesforce LWC Developer Guide](https://developer.salesforce.com/docs/platform/lwc/guide/create-components-directives.html)
|