Skip to content

Commit 293a3c8

Browse files
chore: improve dynamic richtext loading
1 parent b2fe902 commit 293a3c8

File tree

7 files changed

+242
-89
lines changed

7 files changed

+242
-89
lines changed

packages/angular/projects/sdk/src/lib/rich-text-node.component.ts

Lines changed: 70 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -7,66 +7,49 @@ import {
77
ElementRef,
88
Renderer2,
99
OnInit,
10+
OnDestroy,
1011
Type,
11-
ViewContainerRef,
1212
ViewEncapsulation,
1313
Injector,
1414
createComponent,
1515
EnvironmentInjector,
1616
ApplicationRef,
17+
ComponentRef,
18+
signal,
1719
} from '@angular/core';
1820
import { NgComponentOutlet } from '@angular/common';
21+
import { isVoidElement } from '@storyblok/richtext';
1922
import {
20-
STORYBLOK_RICHTEXT_COMPONENTS,
23+
StoryblokRichtextResolver,
2124
type AngularRenderNode,
2225
isTextNode,
2326
isTagNode,
2427
isComponentNode,
2528
} from './richtext.feature';
2629

27-
/**
28-
* Self-closing HTML elements that don't have children.
29-
*/
30-
const VOID_ELEMENTS = new Set([
31-
'area',
32-
'base',
33-
'br',
34-
'col',
35-
'embed',
36-
'hr',
37-
'img',
38-
'input',
39-
'link',
40-
'meta',
41-
'param',
42-
'source',
43-
'track',
44-
'wbr',
45-
]);
46-
4730
/**
4831
* Recursively renders a single rich text AST node.
4932
*
5033
* This component handles three types of nodes:
5134
* - **Text nodes**: Rendered as plain text
5235
* - **Tag nodes**: Rendered as HTML elements with attributes
53-
* - **Component nodes**: Rendered using custom Angular components
36+
* - **Component nodes**: Rendered using custom Angular components (supports lazy loading)
5437
*
5538
* The component uses Angular's `Renderer2` for SSR-safe DOM manipulation.
5639
*
57-
* @internal This component is used internally by `RichTextComponent`.
40+
* @internal This component is used internally by `SbRichTextComponent`.
5841
* Users typically don't need to use this directly, but it's exported
5942
* for custom component implementations that need to render children.
6043
*
6144
* @example Rendering children in a custom component
6245
* ```typescript
6346
* @Component({
6447
* selector: 'app-custom-link',
65-
* imports: [RichTextNodeComponent],
48+
* imports: [SbRichTextNodeComponent],
6649
* template: `
6750
* <a [href]="href()">
6851
* @for (child of children(); track $index) {
69-
* <sb-richtext-node [node]="child" />
52+
* <sb-rich-text-node [node]="child" />
7053
* }
7154
* </a>
7255
* `,
@@ -78,53 +61,40 @@ const VOID_ELEMENTS = new Set([
7861
* ```
7962
*/
8063
@Component({
81-
selector: 'sb-richtext-node',
64+
selector: 'sb-rich-text-node',
8265
standalone: true,
8366
changeDetection: ChangeDetectionStrategy.OnPush,
8467
encapsulation: ViewEncapsulation.None,
8568
imports: [NgComponentOutlet],
8669
template: `
87-
@if (isComponentWithType()) {
70+
@if (componentType()) {
8871
<ng-container *ngComponentOutlet="componentType(); inputs: componentInputs()" />
8972
}
9073
`,
9174
styles: [
9275
`
93-
sb-richtext-node {
76+
sb-rich-text-node {
9477
display: contents;
9578
}
9679
`,
9780
],
9881
})
99-
export class RichTextNodeComponent implements OnInit {
100-
private readonly components = inject(STORYBLOK_RICHTEXT_COMPONENTS);
82+
export class SbRichTextNodeComponent implements OnInit, OnDestroy {
83+
private readonly resolver = inject(StoryblokRichtextResolver);
10184
private readonly renderer = inject(Renderer2);
10285
private readonly el = inject(ElementRef<HTMLElement>);
10386
private readonly injector = inject(Injector);
10487
private readonly envInjector = inject(EnvironmentInjector);
10588
private readonly appRef = inject(ApplicationRef);
10689

90+
/** Track dynamically created child components for cleanup */
91+
private readonly childComponentRefs: ComponentRef<SbRichTextNodeComponent>[] = [];
92+
10793
/** The AST node to render */
10894
readonly node = input.required<AngularRenderNode>();
10995

110-
/**
111-
* Whether this is a component node with a registered component type.
112-
* Used to conditionally render the ngComponentOutlet in the template.
113-
*/
114-
readonly isComponentWithType = computed(() => {
115-
const node = this.node();
116-
if (!isComponentNode(node)) return false;
117-
return this.components[node.component] != null;
118-
});
119-
120-
/**
121-
* The Angular component type to render for component nodes.
122-
*/
123-
readonly componentType = computed<Type<unknown> | null>(() => {
124-
const node = this.node();
125-
if (!isComponentNode(node)) return null;
126-
return this.components[node.component] ?? null;
127-
});
96+
/** Resolved component type (supports lazy loading) */
97+
readonly componentType = signal<Type<unknown> | null>(null);
12898

12999
/**
130100
* The inputs to pass to the dynamic component.
@@ -160,10 +130,52 @@ export class RichTextNodeComponent implements OnInit {
160130
return;
161131
}
162132

163-
// Handle component nodes without a registered component (fallback: render children)
164-
if (isComponentNode(node) && !this.componentType()) {
133+
// Handle component nodes
134+
if (isComponentNode(node)) {
135+
this.resolveAndRenderComponent(node, hostElement);
136+
}
137+
}
138+
139+
ngOnDestroy(): void {
140+
// Clean up all dynamically created child components
141+
for (const ref of this.childComponentRefs) {
142+
this.appRef.detachView(ref.hostView);
143+
ref.destroy();
144+
}
145+
this.childComponentRefs.length = 0;
146+
}
147+
148+
/**
149+
* Resolves and renders a component node, handling both eager and lazy loading.
150+
*/
151+
private resolveAndRenderComponent(
152+
node: { component: string; props: Record<string, unknown>; children?: AngularRenderNode[] },
153+
hostElement: HTMLElement,
154+
): void {
155+
// Try synchronous resolution first (eager or cached)
156+
const syncComponent = this.resolver.getSync(node.component as any);
157+
158+
if (syncComponent) {
159+
this.componentType.set(syncComponent);
160+
return;
161+
}
162+
163+
// Check if the component is registered at all
164+
if (!this.resolver.has(node.component as any)) {
165+
// No component registered - render children as fallback
165166
this.renderChildren(node.children ?? [], hostElement);
167+
return;
166168
}
169+
170+
// Lazy component - resolve asynchronously
171+
this.resolver.resolve(node.component as any).then((component) => {
172+
if (component) {
173+
this.componentType.set(component);
174+
} else {
175+
// Fallback: render children if component couldn't be resolved
176+
this.renderChildren(node.children ?? [], hostElement);
177+
}
178+
});
167179
}
168180

169181
/**
@@ -195,24 +207,27 @@ export class RichTextNodeComponent implements OnInit {
195207
// Append to parent element
196208
this.renderer.appendChild(parent, element);
197209

198-
// Render children inside the element (skip for void elements)
199-
if (!VOID_ELEMENTS.has(node.tag) && node.children?.length) {
210+
// Render children inside the element (skip for void elements like img, br, hr)
211+
if (!isVoidElement(node.tag) && node.children?.length) {
200212
this.renderChildren(node.children, element);
201213
}
202214
}
203215

204216
/**
205-
* Recursively renders child nodes by creating RichTextNodeComponent instances.
217+
* Recursively renders child nodes by creating SbRichTextNodeComponent instances.
206218
* Uses createComponent from @angular/core for proper SSR support.
207219
*/
208220
private renderChildren(children: AngularRenderNode[], parent: HTMLElement | Element): void {
209221
for (const child of children) {
210222
// Create the component using Angular's createComponent
211-
const componentRef = createComponent(RichTextNodeComponent, {
223+
const componentRef = createComponent(SbRichTextNodeComponent, {
212224
environmentInjector: this.envInjector,
213225
elementInjector: this.injector,
214226
});
215227

228+
// Track for cleanup
229+
this.childComponentRefs.push(componentRef);
230+
216231
// Set the input
217232
componentRef.setInput('node', child);
218233

packages/angular/projects/sdk/src/lib/rich-text.component.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import {
55
computed,
66
inject,
77
} from '@angular/core';
8-
import type { StoryblokRichTextNode, StoryblokSegmentType } from '@storyblok/richtext';
8+
import type { StoryblokRichTextNode } from '@storyblok/richtext';
99
import { getRichTextSegments, renderSegments } from '@storyblok/richtext';
1010
import {
11-
STORYBLOK_RICHTEXT_COMPONENTS,
11+
StoryblokRichtextResolver,
1212
createAngularAdapter,
1313
type AngularRenderNode,
1414
} from './richtext.feature';
15-
import { RichTextNodeComponent } from './rich-text-node.component';
15+
import { SbRichTextNodeComponent } from './rich-text-node.component';
1616

1717
/**
1818
* Renders Storyblok rich text content with support for custom component overrides.
@@ -23,15 +23,26 @@ import { RichTextNodeComponent } from './rich-text-node.component';
2323
*
2424
* @example Basic usage
2525
* ```html
26-
* <sb-richtext [doc]="story.content.body" />
26+
* <sb-rich-text [doc]="story.content.body" />
2727
* ```
2828
*
29-
* @example With custom component overrides
29+
* @example With custom component overrides (lazy loading - recommended)
3030
* ```typescript
3131
* // In your providers:
3232
* provideStoryblok(
3333
* { accessToken: 'token' },
3434
* withStoryblokRichtextComponents({
35+
* link: () => import('./custom-link').then(m => m.CustomLinkComponent),
36+
* image: () => import('./optimized-image').then(m => m.OptimizedImageComponent),
37+
* })
38+
* )
39+
* ```
40+
*
41+
* @example With custom component overrides (eager loading)
42+
* ```typescript
43+
* provideStoryblok(
44+
* { accessToken: 'token' },
45+
* withStoryblokRichtextComponents({
3546
* link: CustomLinkComponent,
3647
* image: OptimizedImageComponent,
3748
* })
@@ -42,10 +53,11 @@ import { RichTextNodeComponent } from './rich-text-node.component';
4253
* ```typescript
4354
* @Component({
4455
* selector: 'app-custom-link',
56+
* imports: [SbRichTextNodeComponent],
4557
* template: `
4658
* <a [href]="href()" [target]="target()">
4759
* @for (child of children(); track $index) {
48-
* <sb-richtext-node [node]="child" />
60+
* <sb-rich-text-node [node]="child" />
4961
* }
5062
* </a>
5163
* `,
@@ -58,19 +70,19 @@ import { RichTextNodeComponent } from './rich-text-node.component';
5870
* ```
5971
*/
6072
@Component({
61-
selector: 'sb-richtext',
73+
selector: 'sb-rich-text',
6274
standalone: true,
6375
changeDetection: ChangeDetectionStrategy.OnPush,
64-
imports: [RichTextNodeComponent],
76+
imports: [SbRichTextNodeComponent],
6577
template: `
6678
@for (node of nodes(); track $index) {
67-
<sb-richtext-node [node]="node" />
79+
<sb-rich-text-node [node]="node" />
6880
}
6981
`,
7082
host: { style: 'display: contents' },
7183
})
72-
export class RichTextComponent {
73-
private readonly components = inject(STORYBLOK_RICHTEXT_COMPONENTS);
84+
export class SbRichTextComponent {
85+
private readonly resolver = inject(StoryblokRichtextResolver);
7486

7587
/** The Storyblok rich text document to render */
7688
readonly doc = input.required<StoryblokRichTextNode>();
@@ -87,7 +99,7 @@ export class RichTextComponent {
8799
const adapter = createAngularAdapter();
88100

89101
// 3. Get keys of custom components (these become "component" nodes)
90-
const keys = Object.keys(this.components) as StoryblokSegmentType[];
102+
const keys = this.resolver.getRegisteredTypes();
91103

92104
// 4. Render segments to Angular AST
93105
return renderSegments(segments, adapter, keys);

0 commit comments

Comments
 (0)