@contentful/experiences-svelte 0.5.10 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/ClientExperienceRenderer.svelte +34 -3
  3. package/dist/ClientExperienceRenderer.svelte.d.ts.map +1 -1
  4. package/dist/ComponentError.svelte +35 -0
  5. package/dist/ComponentError.svelte.d.ts +5 -0
  6. package/dist/ComponentError.svelte.d.ts.map +1 -0
  7. package/dist/DebugExperience.svelte +22 -3
  8. package/dist/DebugExperience.svelte.d.ts.map +1 -1
  9. package/dist/DebugExperience.test.js +62 -2
  10. package/dist/NodeRenderer.svelte +62 -6
  11. package/dist/NodeRenderer.svelte.d.ts +3 -1
  12. package/dist/NodeRenderer.svelte.d.ts.map +1 -1
  13. package/dist/NodeRenderer.test.d.ts +2 -0
  14. package/dist/NodeRenderer.test.d.ts.map +1 -0
  15. package/dist/NodeRenderer.test.js +124 -0
  16. package/dist/NodesRenderer.svelte +48 -5
  17. package/dist/NodesRenderer.svelte.d.ts +3 -1
  18. package/dist/NodesRenderer.svelte.d.ts.map +1 -1
  19. package/dist/ServerExperienceRenderer.svelte +21 -3
  20. package/dist/ServerExperienceRenderer.svelte.d.ts.map +1 -1
  21. package/dist/component-props.d.ts +28 -0
  22. package/dist/component-props.d.ts.map +1 -1
  23. package/dist/debug-panel-coverage.ssr.test.d.ts +2 -0
  24. package/dist/debug-panel-coverage.ssr.test.d.ts.map +1 -0
  25. package/dist/debug-panel-coverage.ssr.test.js +61 -0
  26. package/dist/debug-panel-coverage.test.d.ts +2 -0
  27. package/dist/debug-panel-coverage.test.d.ts.map +1 -0
  28. package/dist/debug-panel-coverage.test.js +229 -0
  29. package/dist/index.d.ts +3 -2
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -1
  32. package/dist/nodes-renderer.ssr.test.js +49 -0
  33. package/dist/server-renderer.test.js +5 -3
  34. package/dist/test-fixtures/BrokenFixture.svelte +4 -0
  35. package/dist/test-fixtures/BrokenFixture.svelte.d.ts +19 -0
  36. package/dist/test-fixtures/BrokenFixture.svelte.d.ts.map +1 -0
  37. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
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
+
13
+ ## 0.5.11 (2026-08-27)
14
+
15
+ ### 🧱 Updated Dependencies
16
+
17
+ - Updated client to 0.3.10
18
+ - Updated design to 0.7.10
19
+ - Updated core to 0.7.10
20
+
1
21
  ## 0.5.10 (2026-08-26)
2
22
 
3
23
  ### 🧱 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":"AAqFA,QAAA,MAAM,wBAAwB,kGAAwC,CAAC;AACvE,KAAK,wBAAwB,GAAG,UAAU,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAC5E,eAAe,wBAAwB,CAAC"}
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 &lsquo;{componentId}&rsquo; 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
- return `[DebugExperience: could not serialize plan — ${(err as Error).message}]`;
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={defaultOpen}
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;AA4DjE,QAAA,MAAM,eAAe,0DAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,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 = { nodes: [], viewports: [], fallbackViewportIndex: 0 };
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 = { viewports: [], nodes: [n], fallbackViewportIndex: 0 };
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
  });
@@ -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 { node, config, experience, renderUnknown, slotSnippets }: NodeRendererProps = $props();
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
- <Cmp {...composed} />
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;AAC1D,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;;;;OAIG;IACH,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;CACzC;AA2GH,QAAA,MAAM,YAAY,uDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=NodeRenderer.test.d.ts.map
@@ -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 = $props();
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
- function toSlotSnippets(node: PortableRenderNode): Record<string, Snippet[]> {
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 {node} {config} {experience} {renderUnknown} {slotSnippets} />
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)}