@contentful/experiences-svelte 0.5.11 → 0.6.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -2
  3. package/dist/ClientExperienceRenderer.svelte +55 -9
  4. package/dist/ClientExperienceRenderer.svelte.d.ts.map +1 -1
  5. package/dist/ComponentError.svelte +35 -0
  6. package/dist/ComponentError.svelte.d.ts +5 -0
  7. package/dist/ComponentError.svelte.d.ts.map +1 -0
  8. package/dist/DebugExperience.svelte +22 -3
  9. package/dist/DebugExperience.svelte.d.ts.map +1 -1
  10. package/dist/DebugExperience.test.js +76 -2
  11. package/dist/NodeRenderer.svelte +62 -6
  12. package/dist/NodeRenderer.svelte.d.ts +3 -1
  13. package/dist/NodeRenderer.svelte.d.ts.map +1 -1
  14. package/dist/NodeRenderer.test.d.ts +2 -0
  15. package/dist/NodeRenderer.test.d.ts.map +1 -0
  16. package/dist/NodeRenderer.test.js +124 -0
  17. package/dist/NodesRenderer.svelte +48 -5
  18. package/dist/NodesRenderer.svelte.d.ts +3 -1
  19. package/dist/NodesRenderer.svelte.d.ts.map +1 -1
  20. package/dist/ServerExperienceRenderer.svelte +37 -7
  21. package/dist/ServerExperienceRenderer.svelte.d.ts.map +1 -1
  22. package/dist/component-props.d.ts +30 -5
  23. package/dist/component-props.d.ts.map +1 -1
  24. package/dist/debug-panel-coverage.ssr.test.d.ts +2 -0
  25. package/dist/debug-panel-coverage.ssr.test.d.ts.map +1 -0
  26. package/dist/debug-panel-coverage.ssr.test.js +61 -0
  27. package/dist/debug-panel-coverage.test.d.ts +2 -0
  28. package/dist/debug-panel-coverage.test.d.ts.map +1 -0
  29. package/dist/debug-panel-coverage.test.js +229 -0
  30. package/dist/index.d.ts +5 -4
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +2 -1
  33. package/dist/nodes-renderer.ssr.test.js +49 -0
  34. package/dist/server-renderer.test.js +57 -3
  35. package/dist/test-fixtures/BrokenFixture.svelte +4 -0
  36. package/dist/test-fixtures/BrokenFixture.svelte.d.ts +19 -0
  37. package/dist/test-fixtures/BrokenFixture.svelte.d.ts.map +1 -0
  38. package/dist/types.d.ts +3 -17
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## 0.6.0 (2026-08-28)
2
+
3
+ ### 🧱 Updated Dependencies
4
+
5
+ - Updated client to 0.4.0
6
+ - Updated design to 0.8.0
7
+ - Updated core to 0.8.0
8
+
9
+ ## 0.5.12 (2026-08-28)
10
+
11
+ ### 🚀 Features
12
+
13
+ - error handling and sensible fallbacks for non-happy-path rendering [AIS-364] ([#156](https://github.com/contentful/experiences/pull/156))
14
+
15
+ ### 🧱 Updated Dependencies
16
+
17
+ - Updated client to 0.3.11
18
+ - Updated design to 0.7.11
19
+ - Updated core to 0.7.11
20
+
1
21
  ## 0.5.11 (2026-08-27)
2
22
 
3
23
  ### 🧱 Updated Dependencies
package/README.md CHANGED
@@ -42,7 +42,7 @@ useActiveViewport; // Rune-backed reactive object; you'll rarely need it directl
42
42
  ### Styling + runtime context (helpers)
43
43
 
44
44
  ```ts
45
- getDesignValues<T>(); // Optional helper: the same resolved design record that auto-fills props; read in a $derived to stay reactive
45
+ getDesignValues<T>(); // Escape hatch: the same resolved design record that auto-fills props; read in a $derived to stay reactive
46
46
  toCss(design, options?); // Turns a design record into a plain style object, keeping only real CSS keys
47
47
  getExperience(); // RenderContext: debug, metadata, viewports, activeViewport
48
48
  getContentfulComponent(); // Raw payload for the enclosing node (or undefined)
@@ -50,7 +50,7 @@ getContentfulExperienceTemplate(); // Same, for an enclosing coded Experience Te
50
50
  type ToCssOptions;
51
51
  ```
52
52
 
53
- Resolved design values (viewport-cascaded + token-resolved server-side) are **auto-filled onto your component's props** by key, alongside content. Styling straight from `$props()` is the recommended path; `getDesignValues()` exposes the same record (read it in a `$derived` to stay reactive) for cases props don't cover. Token resolution is configured with `resolveToken` on your `Config` (`type ResolveToken`).
53
+ Resolved design values (viewport-cascaded + token-resolved server-side) are **auto-filled onto your component's props** by key, alongside content. Styling from `$props()` is the one recommended path. `getDesignValues()` exposes the same record (read it in a `$derived` to stay reactive) as an escape hatch. Reach for it only for a nested child that isn't itself a registered component, or for design needed outside the render path (an effect, an imperative measurement) — see [Styling components](../../README.md#styling-components). Token resolution is configured with `resolveToken` on your `Config` (`type ResolveToken`).
54
54
 
55
55
  ### Re-exported types and utilities
56
56
 
@@ -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';
@@ -37,19 +38,55 @@
37
38
  config,
38
39
  initialViewportId,
39
40
  metadata,
40
- debug = false,
41
+ debug,
41
42
  renderUnknown = MissingComponent,
43
+ renderError = ComponentError,
42
44
  }: ClientExperienceRendererProps = $props();
43
45
 
46
+ // `??`, not `||`, so an explicit `debug={false}` overrides a debug-on plan.
47
+ const resolvedDebug = $derived(debug ?? experience?.debug ?? false);
48
+
49
+ // Render-time diagnostics, `$state`-backed so a component that throws well
50
+ // after first paint (a later re-render, an event handler) still makes
51
+ // `<DebugExperience>` re-render with the new diagnostic — a mutated plain
52
+ // array wouldn't be reactive here the way it's fine to be for the
53
+ // synchronous, single-pass server renderer.
54
+ //
55
+ // `queueMicrotask` defers the actual mutation: most of these diagnostics
56
+ // (component-not-registered, malformed-slot,
57
+ // experience-template-not-registered) are reported from NodeRenderer's
58
+ // `{@const}`/`$derived.by` blocks — i.e. from inside a template expression
59
+ // — and Svelte 5 forbids mutating `$state` there directly
60
+ // ("state_unsafe_mutation"). Escaping to a microtask (same "break the
61
+ // synchronous call chain" rationale as core's resolveData deferral)
62
+ // performs the mutation once that expression has finished evaluating.
63
+ // `component-render-error`, reported from `<svelte:boundary onerror>`
64
+ // (already outside any derived/template evaluation), is unaffected by the
65
+ // restriction but deferred too, to keep one code path.
66
+ const renderDiagnostics = $state<Error[]>([]);
67
+ function onDiagnostic(error: Error): void {
68
+ queueMicrotask(() => {
69
+ renderDiagnostics.push(error);
70
+ });
71
+ }
72
+
44
73
  const viewports = $derived(experience?.viewports ?? []);
45
- const tracker = useActiveViewport(viewports, initialViewportId);
74
+ // Seed from the plan so first paint matches the server renderer.
75
+ const seedViewportId = $derived(
76
+ initialViewportId ?? experience?.viewports[experience.fallbackViewportIndex]?.id
77
+ );
78
+ const tracker = useActiveViewport(viewports, seedViewportId);
46
79
 
47
80
  // A $state-backed mirror so descendants reading getExperience() stay
48
81
  // reactive across viewport changes. The fields update in an $effect below.
49
82
  const liveContext = $state<RenderContext>({
50
83
  ...DEFAULT_CONTEXT,
51
- debug,
52
- metadata: { ...DEFAULT_CONTEXT.metadata, ...(metadata ?? {}) },
84
+ debug: debug ?? experience?.debug ?? false,
85
+ metadata: {
86
+ ...DEFAULT_CONTEXT.metadata,
87
+ ...(experience?.metadata ?? {}),
88
+ ...(metadata ?? {}),
89
+ },
53
90
  viewports: experience?.viewports ?? [],
54
91
  activeViewport: experience?.viewports[0] ?? FALLBACK_VIEWPORT,
55
92
  activeViewportIndex: 0,
@@ -65,19 +102,28 @@
65
102
  liveContext.activeViewport = experience.viewports[idx] ?? FALLBACK_VIEWPORT;
66
103
  liveContext.activeViewportIndex = idx;
67
104
  liveContext.fallbackViewportIndex = experience.fallbackViewportIndex;
68
- liveContext.debug = debug;
69
- liveContext.metadata = { ...DEFAULT_CONTEXT.metadata, ...(metadata ?? {}) };
105
+ liveContext.debug = resolvedDebug;
106
+ liveContext.metadata = {
107
+ ...DEFAULT_CONTEXT.metadata,
108
+ ...experience.metadata,
109
+ ...(metadata ?? {}),
110
+ };
70
111
  });
71
112
  </script>
72
113
 
73
114
  {#if experience}
74
- {#if debug}
75
- <DebugExperience {experience} />
76
- {/if}
77
115
  <NodesRenderer
78
116
  nodes={experience.nodes}
79
117
  {config}
80
118
  experience={liveContext}
81
119
  {renderUnknown}
120
+ {renderError}
121
+ {onDiagnostic}
82
122
  />
123
+ {#if resolvedDebug}
124
+ <DebugExperience
125
+ {experience}
126
+ errors={[...(experience.diagnostics ?? []), ...renderDiagnostics]}
127
+ />
128
+ {/if}
83
129
  {/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":"AA+HA,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,14 @@
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
+ metadata: {},
9
+ debug: false,
10
+ diagnostics: [],
11
+ };
5
12
  function node(id, content = {}) {
6
13
  return {
7
14
  registration: { kind: 'component', id },
@@ -36,6 +43,9 @@ describe('DebugExperience.svelte', () => {
36
43
  viewports: [],
37
44
  nodes: [node('button')],
38
45
  fallbackViewportIndex: 0,
46
+ metadata: {},
47
+ debug: false,
48
+ diagnostics: [],
39
49
  };
40
50
  const { container } = render(DebugExperience, { props: { experience: one } });
41
51
  expect(container.innerHTML).toContain('Experience debug — 1 top-level node');
@@ -45,6 +55,9 @@ describe('DebugExperience.svelte', () => {
45
55
  viewports: [],
46
56
  nodes: [templateNode('page')],
47
57
  fallbackViewportIndex: 0,
58
+ metadata: {},
59
+ debug: false,
60
+ diagnostics: [],
48
61
  };
49
62
  const { container } = render(DebugExperience, { props: { experience: plan } });
50
63
  expect(container.innerHTML).toContain('experience template: page');
@@ -54,6 +67,9 @@ describe('DebugExperience.svelte', () => {
54
67
  viewports: [],
55
68
  nodes: [node('button'), node('text')],
56
69
  fallbackViewportIndex: 0,
70
+ metadata: {},
71
+ debug: false,
72
+ diagnostics: [],
57
73
  };
58
74
  const { container } = render(DebugExperience, { props: { experience: plan } });
59
75
  expect(container.innerHTML).toContain('Experience debug — 2 top-level nodes');
@@ -64,6 +80,9 @@ describe('DebugExperience.svelte', () => {
64
80
  viewports: [],
65
81
  nodes: [node('button', { label: 'Go' })],
66
82
  fallbackViewportIndex: 0,
83
+ metadata: {},
84
+ debug: false,
85
+ diagnostics: [],
67
86
  };
68
87
  const { container } = render(DebugExperience, { props: { experience: plan } });
69
88
  expect(container.innerHTML).toContain('registration');
@@ -73,9 +92,64 @@ describe('DebugExperience.svelte', () => {
73
92
  it('degrades a circular reference to a placeholder instead of throwing', () => {
74
93
  const n = node('button');
75
94
  n.props.resolved = { self: n.props };
76
- const plan = { viewports: [], nodes: [n], fallbackViewportIndex: 0 };
95
+ const plan = {
96
+ viewports: [],
97
+ nodes: [n],
98
+ fallbackViewportIndex: 0,
99
+ metadata: {},
100
+ debug: false,
101
+ diagnostics: [],
102
+ };
77
103
  expect(() => render(DebugExperience, { props: { experience: plan } })).not.toThrow();
78
104
  const { container } = render(DebugExperience, { props: { experience: plan } });
79
105
  expect(container.innerHTML).toContain('[Circular]');
80
106
  });
107
+ it('degrades a non-Error throw during serialization to a placeholder instead of crashing', () => {
108
+ const n = node('button');
109
+ // A customer's resolveData (or resolved design value) could stash a
110
+ // getter that throws a non-Error — a bare `throw null`/`throw 'reason'`
111
+ // rather than `throw new Error(...)`. JSON.stringify's replacer walk
112
+ // invokes it, and the safety net's job is to never throw itself either.
113
+ Object.defineProperty(n.props.resolved ?? (n.props.resolved = {}), 'poison', {
114
+ enumerable: true,
115
+ get() {
116
+ throw null;
117
+ },
118
+ });
119
+ const plan = {
120
+ viewports: [],
121
+ nodes: [n],
122
+ fallbackViewportIndex: 0,
123
+ metadata: {},
124
+ debug: false,
125
+ diagnostics: [],
126
+ };
127
+ expect(() => render(DebugExperience, { props: { experience: plan } })).not.toThrow();
128
+ const { container } = render(DebugExperience, { props: { experience: plan } });
129
+ expect(container.innerHTML).toContain('could not serialize plan');
130
+ });
131
+ });
132
+ describe('DebugExperience.svelte — errors prop', () => {
133
+ const warning = new Error('No component registered for id "hero".');
134
+ const error = new Error('Component "card" threw while rendering: boom.');
135
+ it('stays collapsed and renders no error list when errors is empty or omitted', () => {
136
+ const { container } = render(DebugExperience, { props: { experience: emptyPlan } });
137
+ expect(container.querySelector('details').open).toBe(false);
138
+ expect(container.querySelector('[data-experiences-debug-errors]')).toBeNull();
139
+ });
140
+ it('auto-expands even without defaultOpen when there are errors', () => {
141
+ const { container } = render(DebugExperience, {
142
+ props: { experience: emptyPlan, errors: [warning] },
143
+ });
144
+ expect(container.querySelector('details').open).toBe(true);
145
+ });
146
+ it('renders a diagnostic list above the JSON dump', () => {
147
+ const { container } = render(DebugExperience, {
148
+ props: { experience: emptyPlan, errors: [warning, error] },
149
+ });
150
+ expect(container.querySelector('[data-experiences-debug-errors]')).not.toBeNull();
151
+ expect(container.innerHTML).toContain('No component registered for id "hero".');
152
+ expect(container.innerHTML).toContain('Component "card" threw while rendering: boom.');
153
+ expect(container.innerHTML.indexOf('data-experiences-debug-errors')).toBeLessThan(container.innerHTML.indexOf('<pre'));
154
+ });
81
155
  });
@@ -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
+ });