@contentful/experiences-svelte 0.5.11 → 0.5.12
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/CHANGELOG.md +12 -0
- package/dist/ClientExperienceRenderer.svelte +34 -3
- package/dist/ClientExperienceRenderer.svelte.d.ts.map +1 -1
- package/dist/ComponentError.svelte +35 -0
- package/dist/ComponentError.svelte.d.ts +5 -0
- package/dist/ComponentError.svelte.d.ts.map +1 -0
- package/dist/DebugExperience.svelte +22 -3
- package/dist/DebugExperience.svelte.d.ts.map +1 -1
- package/dist/DebugExperience.test.js +62 -2
- package/dist/NodeRenderer.svelte +62 -6
- package/dist/NodeRenderer.svelte.d.ts +3 -1
- package/dist/NodeRenderer.svelte.d.ts.map +1 -1
- package/dist/NodeRenderer.test.d.ts +2 -0
- package/dist/NodeRenderer.test.d.ts.map +1 -0
- package/dist/NodeRenderer.test.js +124 -0
- package/dist/NodesRenderer.svelte +48 -5
- package/dist/NodesRenderer.svelte.d.ts +3 -1
- package/dist/NodesRenderer.svelte.d.ts.map +1 -1
- package/dist/ServerExperienceRenderer.svelte +21 -3
- package/dist/ServerExperienceRenderer.svelte.d.ts.map +1 -1
- package/dist/component-props.d.ts +28 -0
- package/dist/component-props.d.ts.map +1 -1
- package/dist/debug-panel-coverage.ssr.test.d.ts +2 -0
- package/dist/debug-panel-coverage.ssr.test.d.ts.map +1 -0
- package/dist/debug-panel-coverage.ssr.test.js +61 -0
- package/dist/debug-panel-coverage.test.d.ts +2 -0
- package/dist/debug-panel-coverage.test.d.ts.map +1 -0
- package/dist/debug-panel-coverage.test.js +229 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/nodes-renderer.ssr.test.js +49 -0
- package/dist/server-renderer.test.js +5 -3
- package/dist/test-fixtures/BrokenFixture.svelte +4 -0
- package/dist/test-fixtures/BrokenFixture.svelte.d.ts +19 -0
- package/dist/test-fixtures/BrokenFixture.svelte.d.ts.map +1 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 0.5.12 (2026-08-28)
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- error handling and sensible fallbacks for non-happy-path rendering [AIS-364] ([#156](https://github.com/contentful/experiences/pull/156))
|
|
6
|
+
|
|
7
|
+
### 🧱 Updated Dependencies
|
|
8
|
+
|
|
9
|
+
- Updated client to 0.3.11
|
|
10
|
+
- Updated design to 0.7.11
|
|
11
|
+
- Updated core to 0.7.11
|
|
12
|
+
|
|
1
13
|
## 0.5.11 (2026-08-27)
|
|
2
14
|
|
|
3
15
|
### 🧱 Updated Dependencies
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
<script lang="ts">
|
|
12
12
|
import type { ExperienceContext, ViewportDef } from '@contentful/experiences-sdk-core';
|
|
13
13
|
|
|
14
|
+
import ComponentError from './ComponentError.svelte';
|
|
14
15
|
import DebugExperience from './DebugExperience.svelte';
|
|
15
16
|
import MissingComponent from './MissingComponent.svelte';
|
|
16
17
|
import NodesRenderer from './NodesRenderer.svelte';
|
|
@@ -39,8 +40,33 @@
|
|
|
39
40
|
metadata,
|
|
40
41
|
debug = false,
|
|
41
42
|
renderUnknown = MissingComponent,
|
|
43
|
+
renderError = ComponentError,
|
|
42
44
|
}: ClientExperienceRendererProps = $props();
|
|
43
45
|
|
|
46
|
+
// Render-time diagnostics, `$state`-backed so a component that throws well
|
|
47
|
+
// after first paint (a later re-render, an event handler) still makes
|
|
48
|
+
// `<DebugExperience>` re-render with the new diagnostic — a mutated plain
|
|
49
|
+
// array wouldn't be reactive here the way it's fine to be for the
|
|
50
|
+
// synchronous, single-pass server renderer.
|
|
51
|
+
//
|
|
52
|
+
// `queueMicrotask` defers the actual mutation: most of these diagnostics
|
|
53
|
+
// (component-not-registered, malformed-slot,
|
|
54
|
+
// experience-template-not-registered) are reported from NodeRenderer's
|
|
55
|
+
// `{@const}`/`$derived.by` blocks — i.e. from inside a template expression
|
|
56
|
+
// — and Svelte 5 forbids mutating `$state` there directly
|
|
57
|
+
// ("state_unsafe_mutation"). Escaping to a microtask (same "break the
|
|
58
|
+
// synchronous call chain" rationale as core's resolveData deferral)
|
|
59
|
+
// performs the mutation once that expression has finished evaluating.
|
|
60
|
+
// `component-render-error`, reported from `<svelte:boundary onerror>`
|
|
61
|
+
// (already outside any derived/template evaluation), is unaffected by the
|
|
62
|
+
// restriction but deferred too, to keep one code path.
|
|
63
|
+
const renderDiagnostics = $state<Error[]>([]);
|
|
64
|
+
function onDiagnostic(error: Error): void {
|
|
65
|
+
queueMicrotask(() => {
|
|
66
|
+
renderDiagnostics.push(error);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
const viewports = $derived(experience?.viewports ?? []);
|
|
45
71
|
const tracker = useActiveViewport(viewports, initialViewportId);
|
|
46
72
|
|
|
@@ -71,13 +97,18 @@
|
|
|
71
97
|
</script>
|
|
72
98
|
|
|
73
99
|
{#if experience}
|
|
74
|
-
{#if debug}
|
|
75
|
-
<DebugExperience {experience} />
|
|
76
|
-
{/if}
|
|
77
100
|
<NodesRenderer
|
|
78
101
|
nodes={experience.nodes}
|
|
79
102
|
{config}
|
|
80
103
|
experience={liveContext}
|
|
81
104
|
{renderUnknown}
|
|
105
|
+
{renderError}
|
|
106
|
+
{onDiagnostic}
|
|
82
107
|
/>
|
|
108
|
+
{#if debug}
|
|
109
|
+
<DebugExperience
|
|
110
|
+
{experience}
|
|
111
|
+
errors={[...(experience.diagnostics ?? []), ...renderDiagnostics]}
|
|
112
|
+
/>
|
|
113
|
+
{/if}
|
|
83
114
|
{/if}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ClientExperienceRenderer.svelte.d.ts","sourceRoot":"","sources":["../src/ClientExperienceRenderer.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ClientExperienceRenderer.svelte.d.ts","sourceRoot":"","sources":["../src/ClientExperienceRenderer.svelte.ts"],"names":[],"mappings":"AAgHA,QAAA,MAAM,wBAAwB,kGAAwC,CAAC;AACvE,KAAK,wBAAwB,GAAG,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAC5E,eAAe,wBAAwB,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
* Default fallback rendered when a registered component throws while
|
|
3
|
+
* rendering. Sibling of `MissingComponent` — same debug-gated behavior
|
|
4
|
+
* (visible box in debug mode, silent null otherwise), visually distinct
|
|
5
|
+
* (different color/label) so a customer can tell "not registered" apart
|
|
6
|
+
* from "registered but threw" at a glance. The diagnostic + console.warn
|
|
7
|
+
* for this failure mode are recorded once, at the `<svelte:boundary>` call
|
|
8
|
+
* site in `NodeRenderer.svelte` — not here — so they still fire even when a
|
|
9
|
+
* customer overrides `renderError` with their own fallback.
|
|
10
|
+
-->
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import type { ComponentErrorProps } from './component-props.js';
|
|
13
|
+
import { getExperience } from './context.js';
|
|
14
|
+
|
|
15
|
+
let { componentId, nodeId, message }: ComponentErrorProps = $props();
|
|
16
|
+
const experience = getExperience();
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
{#if experience.debug}
|
|
20
|
+
<div
|
|
21
|
+
style="border: 2px solid #b91c1c; padding: 1rem; color: #b91c1c; background: #fff; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8125rem;"
|
|
22
|
+
data-experiences-render-error={componentId}
|
|
23
|
+
>
|
|
24
|
+
<strong>Component ‘{componentId}’ threw while rendering</strong>
|
|
25
|
+
<p style="margin: 0.5rem 0;">
|
|
26
|
+
This component is registered but threw during render. Rendering this fallback instead of
|
|
27
|
+
crashing the surrounding tree.
|
|
28
|
+
</p>
|
|
29
|
+
<pre style="margin: 0; white-space: pre-wrap;">{JSON.stringify(
|
|
30
|
+
{ componentId, nodeId: nodeId ?? null, message: message ?? null },
|
|
31
|
+
null,
|
|
32
|
+
2
|
|
33
|
+
)}</pre>
|
|
34
|
+
</div>
|
|
35
|
+
{/if}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ComponentErrorProps } from './component-props.js';
|
|
2
|
+
declare const ComponentError: import("svelte").Component<ComponentErrorProps, {}, "">;
|
|
3
|
+
type ComponentError = ReturnType<typeof ComponentError>;
|
|
4
|
+
export default ComponentError;
|
|
5
|
+
//# sourceMappingURL=ComponentError.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ComponentError.svelte.d.ts","sourceRoot":"","sources":["../src/ComponentError.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AA6BhE,QAAA,MAAM,cAAc,yDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
|
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
<script lang="ts">
|
|
15
15
|
import type { DebugExperienceProps } from './component-props.js';
|
|
16
16
|
|
|
17
|
-
let { experience, defaultOpen = false }: DebugExperienceProps = $props();
|
|
17
|
+
let { experience, defaultOpen = false, errors = [] }: DebugExperienceProps = $props();
|
|
18
|
+
|
|
19
|
+
// Auto-expand whenever there's something to see, even if the caller didn't
|
|
20
|
+
// explicitly ask — a beta customer shouldn't have to know to click into a
|
|
21
|
+
// collapsed panel to discover that something went wrong.
|
|
22
|
+
const open = $derived(defaultOpen || errors.length > 0);
|
|
18
23
|
|
|
19
24
|
const nodeCount = $derived(experience.nodes.length);
|
|
20
25
|
// Coded Experience Templates are ordinary top-level nodes, so name them from
|
|
@@ -52,7 +57,12 @@
|
|
|
52
57
|
2
|
|
53
58
|
);
|
|
54
59
|
} catch (err) {
|
|
55
|
-
|
|
60
|
+
// `err` isn't guaranteed to be an Error — a getter deep in customer data
|
|
61
|
+
// (a resolveData result, resolved design values) can `throw null` or
|
|
62
|
+
// `throw 'reason'` during JSON.stringify's replacer walk, and this
|
|
63
|
+
// fallback's whole job is to never throw itself.
|
|
64
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
65
|
+
return `[DebugExperience: could not serialize plan — ${reason}]`;
|
|
56
66
|
}
|
|
57
67
|
}
|
|
58
68
|
|
|
@@ -60,7 +70,7 @@
|
|
|
60
70
|
</script>
|
|
61
71
|
|
|
62
72
|
<details
|
|
63
|
-
open
|
|
73
|
+
{open}
|
|
64
74
|
data-experiences-debug
|
|
65
75
|
style="margin: 1rem 0; border: 1px solid #6b7280; border-radius: 6px; background: #0b1021; color: #e2e8f0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.75rem; overflow: hidden;"
|
|
66
76
|
>
|
|
@@ -69,6 +79,15 @@
|
|
|
69
79
|
>
|
|
70
80
|
{summary}
|
|
71
81
|
</summary>
|
|
82
|
+
{#if errors.length > 0}
|
|
83
|
+
<!-- Deliberately unstyled for now — this just needs to make the data
|
|
84
|
+
visible, not console-only. Visual treatment can grow later. -->
|
|
85
|
+
<ul data-experiences-debug-errors>
|
|
86
|
+
{#each errors as error, index (index)}
|
|
87
|
+
<li>{error.message}</li>
|
|
88
|
+
{/each}
|
|
89
|
+
</ul>
|
|
90
|
+
{/if}
|
|
72
91
|
<pre
|
|
73
92
|
style="margin: 0; padding: 0.75rem; overflow: auto; max-height: 32rem; white-space: pre-wrap; word-break: break-word;">{json}</pre>
|
|
74
93
|
</details>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DebugExperience.svelte.d.ts","sourceRoot":"","sources":["../src/DebugExperience.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"DebugExperience.svelte.d.ts","sourceRoot":"","sources":["../src/DebugExperience.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AA8EjE,QAAA,MAAM,eAAe,0DAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { render } from '@testing-library/svelte';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
import DebugExperience from './DebugExperience.svelte';
|
|
4
|
-
const emptyPlan = {
|
|
4
|
+
const emptyPlan = {
|
|
5
|
+
nodes: [],
|
|
6
|
+
viewports: [],
|
|
7
|
+
fallbackViewportIndex: 0,
|
|
8
|
+
diagnostics: [],
|
|
9
|
+
};
|
|
5
10
|
function node(id, content = {}) {
|
|
6
11
|
return {
|
|
7
12
|
registration: { kind: 'component', id },
|
|
@@ -36,6 +41,7 @@ describe('DebugExperience.svelte', () => {
|
|
|
36
41
|
viewports: [],
|
|
37
42
|
nodes: [node('button')],
|
|
38
43
|
fallbackViewportIndex: 0,
|
|
44
|
+
diagnostics: [],
|
|
39
45
|
};
|
|
40
46
|
const { container } = render(DebugExperience, { props: { experience: one } });
|
|
41
47
|
expect(container.innerHTML).toContain('Experience debug — 1 top-level node');
|
|
@@ -45,6 +51,7 @@ describe('DebugExperience.svelte', () => {
|
|
|
45
51
|
viewports: [],
|
|
46
52
|
nodes: [templateNode('page')],
|
|
47
53
|
fallbackViewportIndex: 0,
|
|
54
|
+
diagnostics: [],
|
|
48
55
|
};
|
|
49
56
|
const { container } = render(DebugExperience, { props: { experience: plan } });
|
|
50
57
|
expect(container.innerHTML).toContain('experience template: page');
|
|
@@ -54,6 +61,7 @@ describe('DebugExperience.svelte', () => {
|
|
|
54
61
|
viewports: [],
|
|
55
62
|
nodes: [node('button'), node('text')],
|
|
56
63
|
fallbackViewportIndex: 0,
|
|
64
|
+
diagnostics: [],
|
|
57
65
|
};
|
|
58
66
|
const { container } = render(DebugExperience, { props: { experience: plan } });
|
|
59
67
|
expect(container.innerHTML).toContain('Experience debug — 2 top-level nodes');
|
|
@@ -64,6 +72,7 @@ describe('DebugExperience.svelte', () => {
|
|
|
64
72
|
viewports: [],
|
|
65
73
|
nodes: [node('button', { label: 'Go' })],
|
|
66
74
|
fallbackViewportIndex: 0,
|
|
75
|
+
diagnostics: [],
|
|
67
76
|
};
|
|
68
77
|
const { container } = render(DebugExperience, { props: { experience: plan } });
|
|
69
78
|
expect(container.innerHTML).toContain('registration');
|
|
@@ -73,9 +82,60 @@ describe('DebugExperience.svelte', () => {
|
|
|
73
82
|
it('degrades a circular reference to a placeholder instead of throwing', () => {
|
|
74
83
|
const n = node('button');
|
|
75
84
|
n.props.resolved = { self: n.props };
|
|
76
|
-
const plan = {
|
|
85
|
+
const plan = {
|
|
86
|
+
viewports: [],
|
|
87
|
+
nodes: [n],
|
|
88
|
+
fallbackViewportIndex: 0,
|
|
89
|
+
diagnostics: [],
|
|
90
|
+
};
|
|
77
91
|
expect(() => render(DebugExperience, { props: { experience: plan } })).not.toThrow();
|
|
78
92
|
const { container } = render(DebugExperience, { props: { experience: plan } });
|
|
79
93
|
expect(container.innerHTML).toContain('[Circular]');
|
|
80
94
|
});
|
|
95
|
+
it('degrades a non-Error throw during serialization to a placeholder instead of crashing', () => {
|
|
96
|
+
const n = node('button');
|
|
97
|
+
// A customer's resolveData (or resolved design value) could stash a
|
|
98
|
+
// getter that throws a non-Error — a bare `throw null`/`throw 'reason'`
|
|
99
|
+
// rather than `throw new Error(...)`. JSON.stringify's replacer walk
|
|
100
|
+
// invokes it, and the safety net's job is to never throw itself either.
|
|
101
|
+
Object.defineProperty(n.props.resolved ?? (n.props.resolved = {}), 'poison', {
|
|
102
|
+
enumerable: true,
|
|
103
|
+
get() {
|
|
104
|
+
throw null;
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const plan = {
|
|
108
|
+
viewports: [],
|
|
109
|
+
nodes: [n],
|
|
110
|
+
fallbackViewportIndex: 0,
|
|
111
|
+
diagnostics: [],
|
|
112
|
+
};
|
|
113
|
+
expect(() => render(DebugExperience, { props: { experience: plan } })).not.toThrow();
|
|
114
|
+
const { container } = render(DebugExperience, { props: { experience: plan } });
|
|
115
|
+
expect(container.innerHTML).toContain('could not serialize plan');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe('DebugExperience.svelte — errors prop', () => {
|
|
119
|
+
const warning = new Error('No component registered for id "hero".');
|
|
120
|
+
const error = new Error('Component "card" threw while rendering: boom.');
|
|
121
|
+
it('stays collapsed and renders no error list when errors is empty or omitted', () => {
|
|
122
|
+
const { container } = render(DebugExperience, { props: { experience: emptyPlan } });
|
|
123
|
+
expect(container.querySelector('details').open).toBe(false);
|
|
124
|
+
expect(container.querySelector('[data-experiences-debug-errors]')).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
it('auto-expands even without defaultOpen when there are errors', () => {
|
|
127
|
+
const { container } = render(DebugExperience, {
|
|
128
|
+
props: { experience: emptyPlan, errors: [warning] },
|
|
129
|
+
});
|
|
130
|
+
expect(container.querySelector('details').open).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
it('renders a diagnostic list above the JSON dump', () => {
|
|
133
|
+
const { container } = render(DebugExperience, {
|
|
134
|
+
props: { experience: emptyPlan, errors: [warning, error] },
|
|
135
|
+
});
|
|
136
|
+
expect(container.querySelector('[data-experiences-debug-errors]')).not.toBeNull();
|
|
137
|
+
expect(container.innerHTML).toContain('No component registered for id "hero".');
|
|
138
|
+
expect(container.innerHTML).toContain('Component "card" threw while rendering: boom.');
|
|
139
|
+
expect(container.innerHTML.indexOf('data-experiences-debug-errors')).toBeLessThan(container.innerHTML.indexOf('<pre'));
|
|
140
|
+
});
|
|
81
141
|
});
|
package/dist/NodeRenderer.svelte
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
setContentfulExperienceTemplate,
|
|
20
20
|
setResolvedDesign,
|
|
21
21
|
} from './context.js';
|
|
22
|
-
import type { RenderUnknown } from './component-props.js';
|
|
22
|
+
import type { DiagnosticReporter, RenderError, RenderUnknown } from './component-props.js';
|
|
23
23
|
import {
|
|
24
24
|
normalizeComponentRegistration,
|
|
25
25
|
normalizeExperienceTemplateRegistration,
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
config: Config;
|
|
35
35
|
experience: RenderContext;
|
|
36
36
|
renderUnknown: RenderUnknown;
|
|
37
|
+
renderError: RenderError;
|
|
38
|
+
onDiagnostic: DiagnosticReporter;
|
|
37
39
|
/**
|
|
38
40
|
* Pre-rendered slot children keyed by slot name — one zero-arg Snippet per
|
|
39
41
|
* child node. Spread onto the customer component as named props, so a slot
|
|
@@ -42,11 +44,33 @@
|
|
|
42
44
|
slotSnippets: Record<string, Snippet[]>;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
let {
|
|
47
|
+
let {
|
|
48
|
+
node,
|
|
49
|
+
config,
|
|
50
|
+
experience,
|
|
51
|
+
renderUnknown,
|
|
52
|
+
renderError,
|
|
53
|
+
onDiagnostic,
|
|
54
|
+
slotSnippets,
|
|
55
|
+
}: NodeRendererProps = $props();
|
|
46
56
|
|
|
47
57
|
const { kind, id } = node.registration;
|
|
48
58
|
const isExperienceTemplate = kind === 'experienceTemplate';
|
|
49
59
|
|
|
60
|
+
// Any reactive re-run of this component's `$derived`/`{@const}` blocks
|
|
61
|
+
// (e.g. an ancestor's viewport change) would otherwise re-report the same
|
|
62
|
+
// diagnostic every time — matches Angular's `lastDiagnostics` guard in
|
|
63
|
+
// NodeRenderEngine and React's equivalent in NodeRenderer. `reported` is a
|
|
64
|
+
// plain (non-reactive) binding, stable for this component instance's
|
|
65
|
+
// lifetime — mutating it doesn't itself trigger reactivity, it's purely a
|
|
66
|
+
// guard for calls this component makes.
|
|
67
|
+
const reported = new Set<string>();
|
|
68
|
+
function reportOnce(error: Error): void {
|
|
69
|
+
if (reported.has(error.message)) return;
|
|
70
|
+
reported.add(error.message);
|
|
71
|
+
onDiagnostic(error);
|
|
72
|
+
}
|
|
73
|
+
|
|
50
74
|
const entry = $derived(
|
|
51
75
|
isExperienceTemplate ? config.experienceTemplates?.[id] : config.components[id]
|
|
52
76
|
);
|
|
@@ -113,23 +137,55 @@
|
|
|
113
137
|
// unwrapped — the content survives, the diagnostic names what's missing.
|
|
114
138
|
const orphanedSnippets = $derived.by(() => {
|
|
115
139
|
if (componentConfig || !isExperienceTemplate) return null;
|
|
140
|
+
const message = `No experience template registered for id "${id}". Rendering its slot children without the experience template wrapper.`;
|
|
116
141
|
if (typeof console !== 'undefined') {
|
|
117
|
-
console.warn(
|
|
118
|
-
`[@contentful/experiences-svelte] No experience template registered for id "${id}". Rendering its slot children without the experience template wrapper.`
|
|
119
|
-
);
|
|
142
|
+
console.warn(`[@contentful/experiences-svelte] ${message}`);
|
|
120
143
|
}
|
|
144
|
+
reportOnce(new Error(message));
|
|
121
145
|
return Object.values(slotSnippets).flat();
|
|
122
146
|
});
|
|
147
|
+
|
|
148
|
+
// `MissingComponent` (the default `renderUnknown`) does its own
|
|
149
|
+
// console.warn via an `$effect`; a custom override may not, so the
|
|
150
|
+
// diagnostic is recorded here regardless of which fallback ends up
|
|
151
|
+
// rendering.
|
|
152
|
+
function reportMissingComponent(): true {
|
|
153
|
+
const message = `No component registered for id "${id}"${node.nodeId ? ` (nodeId: ${node.nodeId})` : ''}.`;
|
|
154
|
+
reportOnce(new Error(message));
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Reported from `<svelte:boundary onerror={...}>` when the customer's
|
|
159
|
+
// component throws during render. See the README's error-handling section
|
|
160
|
+
// for the SSR/CSR asymmetry: `<svelte:boundary>` only catches client-side —
|
|
161
|
+
// a throw during SvelteKit's server render still fails the whole render.
|
|
162
|
+
function reportRenderError(error: unknown): void {
|
|
163
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
164
|
+
const message =
|
|
165
|
+
`Component "${id}" (${kind}${node.nodeId ? `, node "${node.nodeId}"` : ''}) threw while ` +
|
|
166
|
+
`rendering: ${reason}. Rendering the error fallback instead of crashing the surrounding tree.`;
|
|
167
|
+
if (typeof console !== 'undefined') {
|
|
168
|
+
console.warn(`[@contentful/experiences-svelte] ${message}`);
|
|
169
|
+
}
|
|
170
|
+
onDiagnostic(new Error(message, { cause: error instanceof Error ? error : undefined }));
|
|
171
|
+
}
|
|
123
172
|
</script>
|
|
124
173
|
|
|
125
174
|
{#if componentConfig && composed}
|
|
126
175
|
{@const Cmp = componentConfig.component}
|
|
127
|
-
|
|
176
|
+
{@const ErrorFallback = renderError}
|
|
177
|
+
<svelte:boundary onerror={(error) => reportRenderError(error)}>
|
|
178
|
+
<Cmp {...composed} />
|
|
179
|
+
{#snippet failed()}
|
|
180
|
+
<ErrorFallback componentId={id} nodeId={node.nodeId} />
|
|
181
|
+
{/snippet}
|
|
182
|
+
</svelte:boundary>
|
|
128
183
|
{:else if orphanedSnippets}
|
|
129
184
|
{#each orphanedSnippets as childSnippet, index (index)}
|
|
130
185
|
{@render childSnippet()}
|
|
131
186
|
{/each}
|
|
132
187
|
{:else}
|
|
188
|
+
{@const _reported = reportMissingComponent()}
|
|
133
189
|
{@const Unknown = renderUnknown}
|
|
134
190
|
<Unknown
|
|
135
191
|
componentId={id}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import type { PortableRenderNode } from '@contentful/experiences-sdk-core';
|
|
3
|
-
import type { RenderUnknown } from './component-props.js';
|
|
3
|
+
import type { DiagnosticReporter, RenderError, RenderUnknown } from './component-props.js';
|
|
4
4
|
import { type Config, type RenderContext } from './types.js';
|
|
5
5
|
interface NodeRendererProps {
|
|
6
6
|
node: PortableRenderNode;
|
|
7
7
|
config: Config;
|
|
8
8
|
experience: RenderContext;
|
|
9
9
|
renderUnknown: RenderUnknown;
|
|
10
|
+
renderError: RenderError;
|
|
11
|
+
onDiagnostic: DiagnosticReporter;
|
|
10
12
|
/**
|
|
11
13
|
* Pre-rendered slot children keyed by slot name — one zero-arg Snippet per
|
|
12
14
|
* child node. Spread onto the customer component as named props, so a slot
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NodeRenderer.svelte.d.ts","sourceRoot":"","sources":["../src/NodeRenderer.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC;AAQ3E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"NodeRenderer.svelte.d.ts","sourceRoot":"","sources":["../src/NodeRenderer.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC;AAQ3E,OAAO,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC3F,OAAO,EAKH,KAAK,MAAM,EACX,KAAK,aAAa,EACnB,MAAM,YAAY,CAAC;AAGpB,UAAU,iBAAiB;IACzB,IAAI,EAAE,kBAAkB,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,aAAa,CAAC;IAC1B,aAAa,EAAE,aAAa,CAAC;IAC7B,WAAW,EAAE,WAAW,CAAC;IACzB,YAAY,EAAE,kBAAkB,CAAC;IACjC;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;CACzC;AAgKH,QAAA,MAAM,YAAY,uDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NodeRenderer.test.d.ts","sourceRoot":"","sources":["../src/NodeRenderer.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `component-render-error` isolation on the CLIENT (jsdom, client-compiled
|
|
3
|
+
* output — the default `vitest.config.ts`). `<svelte:boundary>` genuinely
|
|
4
|
+
* catches here, unlike under `svelte/server` — see `nodes-renderer.ssr.test.ts`
|
|
5
|
+
* for that documented, proven gap. Both `ServerExperienceRenderer` and
|
|
6
|
+
* `ClientExperienceRenderer` share the same `NodesRenderer`/`NodeRenderer`
|
|
7
|
+
* boundary code, so both are exercised — the distinction between the two
|
|
8
|
+
* components is about viewport-tracking reactivity, not error handling.
|
|
9
|
+
*/
|
|
10
|
+
import { render } from '@testing-library/svelte';
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
// `ClientExperienceRenderer`'s onDiagnostic defers via `queueMicrotask` (see
|
|
13
|
+
// its own comment for why); a test asserting against the debug panel needs to
|
|
14
|
+
// let that microtask settle first.
|
|
15
|
+
async function flushMicrotasks() {
|
|
16
|
+
await new Promise((resolve) => queueMicrotask(resolve));
|
|
17
|
+
}
|
|
18
|
+
import { resolveExperience } from '@contentful/experiences-sdk-core';
|
|
19
|
+
import ClientExperienceRenderer from './ClientExperienceRenderer.svelte';
|
|
20
|
+
import ServerExperienceRenderer from './ServerExperienceRenderer.svelte';
|
|
21
|
+
import BrokenFixture from './test-fixtures/BrokenFixture.svelte';
|
|
22
|
+
import ButtonFixture from './test-fixtures/ButtonFixture.svelte';
|
|
23
|
+
const VIEWPORTS = [{ id: 'desktop', query: '*', displayName: 'Desktop', previewSize: '100%' }];
|
|
24
|
+
function componentNode(typeId, rest = {}) {
|
|
25
|
+
return {
|
|
26
|
+
component: {
|
|
27
|
+
sys: {
|
|
28
|
+
type: 'ResourceLink',
|
|
29
|
+
linkType: 'Contentful:Component',
|
|
30
|
+
urn: `crn:contentful:::experience:spaces/$self/environments/$self/components/${typeId}`,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
...rest,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
describe.each([
|
|
37
|
+
['ServerExperienceRenderer', ServerExperienceRenderer],
|
|
38
|
+
['ClientExperienceRenderer', ClientExperienceRenderer],
|
|
39
|
+
])('%s — component-render-error, client-side catch', (name, Renderer) => {
|
|
40
|
+
it('isolates the failing node — sibling still renders, records a diagnostic', async () => {
|
|
41
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
42
|
+
try {
|
|
43
|
+
const payload = {
|
|
44
|
+
viewports: VIEWPORTS,
|
|
45
|
+
nodes: [
|
|
46
|
+
componentNode('broken', { id: 'b' }),
|
|
47
|
+
componentNode('contentful-button', { id: 'f', contentProperties: { label: 'sibling' } }),
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
const config = {
|
|
51
|
+
components: { broken: BrokenFixture, 'contentful-button': ButtonFixture },
|
|
52
|
+
};
|
|
53
|
+
const plan = await resolveExperience(payload, config);
|
|
54
|
+
const { container } = render(Renderer, {
|
|
55
|
+
props: { experience: plan, config, debug: true },
|
|
56
|
+
});
|
|
57
|
+
expect(container.querySelector('button')?.textContent).toBe('sibling');
|
|
58
|
+
expect(container.querySelector('[data-experiences-render-error="broken"]')).not.toBeNull();
|
|
59
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('boom'));
|
|
60
|
+
await flushMicrotasks();
|
|
61
|
+
// Only ClientExperienceRenderer's debug panel is reactive
|
|
62
|
+
// ($state-backed renderDiagnostics): the boundary's onerror resolves
|
|
63
|
+
// after the initial render even under this client-mounted harness, and
|
|
64
|
+
// ServerExperienceRenderer's plain-array collection is evaluated once,
|
|
65
|
+
// matching its documented "one synchronous SSR pass" contract — real
|
|
66
|
+
// `svelte/server` never reaches the catching code at all (see the
|
|
67
|
+
// README's SSR/CSR asymmetry section), so this component never shows
|
|
68
|
+
// this diagnostic in the debug panel in any real deployment either.
|
|
69
|
+
if (name === 'ClientExperienceRenderer') {
|
|
70
|
+
const errorList = container.querySelector('[data-experiences-debug-errors]');
|
|
71
|
+
expect(errorList).not.toBeNull();
|
|
72
|
+
expect(errorList.textContent).toContain('Component "broken"');
|
|
73
|
+
expect(errorList.textContent).toContain('boom');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
warn.mockRestore();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
it('renders nothing from the default fallback when debug is off, but still warns', async () => {
|
|
81
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
82
|
+
try {
|
|
83
|
+
const payload = {
|
|
84
|
+
viewports: VIEWPORTS,
|
|
85
|
+
nodes: [componentNode('broken', { id: 'b' })],
|
|
86
|
+
};
|
|
87
|
+
const config = { components: { broken: BrokenFixture } };
|
|
88
|
+
const plan = await resolveExperience(payload, config);
|
|
89
|
+
const { container } = render(Renderer, {
|
|
90
|
+
props: { experience: plan, config },
|
|
91
|
+
});
|
|
92
|
+
expect(container.querySelector('[data-experiences-render-error]')).toBeNull();
|
|
93
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('boom'));
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
warn.mockRestore();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('ClientExperienceRenderer — render-time diagnostics dedupe across re-renders', () => {
|
|
101
|
+
it('reports a persistently-unregistered component only once, not once per re-render', async () => {
|
|
102
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
103
|
+
try {
|
|
104
|
+
const payload = {
|
|
105
|
+
viewports: VIEWPORTS,
|
|
106
|
+
nodes: [componentNode('missing', { id: 'm' })],
|
|
107
|
+
};
|
|
108
|
+
const config = { components: {} };
|
|
109
|
+
const plan = await resolveExperience(payload, config);
|
|
110
|
+
const { container, rerender } = render(ClientExperienceRenderer, {
|
|
111
|
+
props: { experience: plan, config, debug: true },
|
|
112
|
+
});
|
|
113
|
+
// Force NodeRenderer's reactive blocks to re-run without any real new
|
|
114
|
+
// occurrence — an ancestor re-render (a viewport change, an unrelated
|
|
115
|
+
// parent update) does the same thing in production.
|
|
116
|
+
await rerender({ experience: plan, config, debug: true });
|
|
117
|
+
expect(container.querySelectorAll('[data-experiences-debug-errors] li')).toHaveLength(1);
|
|
118
|
+
expect(container.textContent).toContain('No component registered for id "missing"');
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
warn.mockRestore();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import type { PortableRenderNode } from '@contentful/experiences-sdk-core';
|
|
27
27
|
|
|
28
28
|
import NodeRenderer from './NodeRenderer.svelte';
|
|
29
|
-
import type { RenderUnknown } from './component-props.js';
|
|
29
|
+
import type { DiagnosticReporter, RenderError, RenderUnknown } from './component-props.js';
|
|
30
30
|
import type { Config, RenderContext } from './types.js';
|
|
31
31
|
|
|
32
32
|
interface NodesRendererProps {
|
|
@@ -34,9 +34,24 @@
|
|
|
34
34
|
config: Config;
|
|
35
35
|
experience: RenderContext;
|
|
36
36
|
renderUnknown: RenderUnknown;
|
|
37
|
+
renderError: RenderError;
|
|
38
|
+
onDiagnostic: DiagnosticReporter;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
let { nodes, config, experience, renderUnknown }: NodesRendererProps =
|
|
41
|
+
let { nodes, config, experience, renderUnknown, renderError, onDiagnostic }: NodesRendererProps =
|
|
42
|
+
$props();
|
|
43
|
+
|
|
44
|
+
// Same re-run-doesn't-mean-new-occurrence problem as NodeRenderer.svelte's
|
|
45
|
+
// `reportOnce` — `renderNode`'s `{@const}`s re-run per node on every
|
|
46
|
+
// reactive re-render of the `{#each}` block. One Set covers every node in
|
|
47
|
+
// this list since the diagnostic's own context (nodeId/slotName) already
|
|
48
|
+
// makes the signature unique per node+slot.
|
|
49
|
+
const reportedSlots = new Set<string>();
|
|
50
|
+
function reportSlotDiagnosticOnce(error: Error): void {
|
|
51
|
+
if (reportedSlots.has(error.message)) return;
|
|
52
|
+
reportedSlots.add(error.message);
|
|
53
|
+
onDiagnostic(error);
|
|
54
|
+
}
|
|
40
55
|
|
|
41
56
|
// Curry the parameterized `renderNode` snippet into a zero-arg Snippet bound
|
|
42
57
|
// to one node: forward the anchor (the renderer, under SSR) and supply the
|
|
@@ -62,9 +77,29 @@
|
|
|
62
77
|
// themselves are compile-time entities, but the merged props object is
|
|
63
78
|
// plain runtime data — so an unbounded set of payload-driven slot names is
|
|
64
79
|
// fine as long as each snippet comes from `toChildSnippet`.
|
|
65
|
-
|
|
80
|
+
//
|
|
81
|
+
// Defensive: `node.slots[x]` is typed as an array, but a hand-built
|
|
82
|
+
// PortableRenderPlan (a supported path — customers can construct one
|
|
83
|
+
// directly instead of going through `resolveExperience`) is not
|
|
84
|
+
// type-checked at runtime. Warn + drop rather than letting `.map` throw.
|
|
85
|
+
function toSlotSnippets(
|
|
86
|
+
node: PortableRenderNode,
|
|
87
|
+
onDiagnosticFn: DiagnosticReporter
|
|
88
|
+
): Record<string, Snippet[]> {
|
|
66
89
|
const slotSnippets: Record<string, Snippet[]> = {};
|
|
67
90
|
for (const [slotName, children] of Object.entries(node.slots)) {
|
|
91
|
+
if (!Array.isArray(children)) {
|
|
92
|
+
const { kind, id } = node.registration;
|
|
93
|
+
const message =
|
|
94
|
+
`Slot "${slotName}" on ${kind} "${id}"${node.nodeId ? ` (node "${node.nodeId}")` : ''} ` +
|
|
95
|
+
`is not an array of nodes; rendering it as empty instead of throwing.`;
|
|
96
|
+
if (typeof console !== 'undefined') {
|
|
97
|
+
console.warn(`[@contentful/experiences-svelte] ${message}`);
|
|
98
|
+
}
|
|
99
|
+
onDiagnosticFn(new Error(message));
|
|
100
|
+
slotSnippets[slotName] = [];
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
68
103
|
slotSnippets[slotName] = children.map(toChildSnippet);
|
|
69
104
|
}
|
|
70
105
|
return slotSnippets;
|
|
@@ -73,8 +108,16 @@
|
|
|
73
108
|
|
|
74
109
|
{#snippet renderNode(nodeArg: NodeArg)}
|
|
75
110
|
{@const node = unwrapNode(nodeArg)}
|
|
76
|
-
{@const slotSnippets = toSlotSnippets(node)}
|
|
77
|
-
<NodeRenderer
|
|
111
|
+
{@const slotSnippets = toSlotSnippets(node, reportSlotDiagnosticOnce)}
|
|
112
|
+
<NodeRenderer
|
|
113
|
+
{node}
|
|
114
|
+
{config}
|
|
115
|
+
{experience}
|
|
116
|
+
{renderUnknown}
|
|
117
|
+
{renderError}
|
|
118
|
+
{onDiagnostic}
|
|
119
|
+
{slotSnippets}
|
|
120
|
+
/>
|
|
78
121
|
{/snippet}
|
|
79
122
|
|
|
80
123
|
{#each nodes as node, index (node.nodeId ?? index)}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { PortableRenderNode } from '@contentful/experiences-sdk-core';
|
|
2
|
-
import type { RenderUnknown } from './component-props.js';
|
|
2
|
+
import type { DiagnosticReporter, RenderError, RenderUnknown } from './component-props.js';
|
|
3
3
|
import type { Config, RenderContext } from './types.js';
|
|
4
4
|
interface NodesRendererProps {
|
|
5
5
|
nodes: PortableRenderNode[];
|
|
6
6
|
config: Config;
|
|
7
7
|
experience: RenderContext;
|
|
8
8
|
renderUnknown: RenderUnknown;
|
|
9
|
+
renderError: RenderError;
|
|
10
|
+
onDiagnostic: DiagnosticReporter;
|
|
9
11
|
}
|
|
10
12
|
declare const NodesRenderer: import("svelte").Component<NodesRendererProps, {}, "">;
|
|
11
13
|
type NodesRenderer = ReturnType<typeof NodesRenderer>;
|