@crossworks/share-ui 0.232.9 → 0.232.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossworks/share-ui",
3
- "version": "0.232.9",
3
+ "version": "0.232.10",
4
4
  "description": "The server-rendered share surface — the /s/<token> presenters, view-payload contract, mini-app sandbox, and the few primitives they need. Lives in MANTLE (the server renders these), published for jackdaw to consume; may depend only on @mantle/{client-types,content-core} (the jackdaw-repo-split boundary).",
5
5
  "exports": {
6
6
  "./app-presenter": "./src/app-presenter.tsx",
@@ -32,8 +32,8 @@
32
32
  "./styles/app.css": "./styles/app.css"
33
33
  },
34
34
  "dependencies": {
35
- "@mantle/client-types": "npm:@crossworks/client-types@0.232.9",
36
- "@mantle/content-core": "npm:@crossworks/content-core@0.232.9",
35
+ "@mantle/client-types": "npm:@crossworks/client-types@0.232.10",
36
+ "@mantle/content-core": "npm:@crossworks/content-core@0.232.10",
37
37
  "@radix-ui/react-label": "^2.1.12",
38
38
  "@radix-ui/react-slot": "^1.3.0",
39
39
  "class-variance-authority": "^0.7.1",
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The frame document is the only place that can witness a MODULE-LEVEL failure
3
+ * — a link error or a module-scope throw happens before any component exists,
4
+ * so the kit's ErrorBoundary cannot catch it. Without the reporter the parent
5
+ * has no signal at all and can only time out with a causeless message, which is
6
+ * exactly what a wrong `@host` import cost in the field.
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { buildAppFrameHtml } from './app-frame-html';
10
+
11
+ const frame = (bundleCode: string) =>
12
+ buildAppFrameHtml({
13
+ bundleCode,
14
+ appCss: '',
15
+ importMapJson: '{"imports":{}}',
16
+ cls: '',
17
+ colorTheme: null,
18
+ viewport: true,
19
+ });
20
+
21
+ describe('buildAppFrameHtml — error reporting', () => {
22
+ it('installs the reporter BEFORE the app module, or it cannot catch boot', () => {
23
+ const html = frame('console.log(1)');
24
+ const reporter = html.indexOf("addEventListener('error'");
25
+ const appModule = html.indexOf('<script type="module">');
26
+ expect(reporter).toBeGreaterThan(-1);
27
+ expect(appModule).toBeGreaterThan(-1);
28
+ expect(reporter).toBeLessThan(appModule);
29
+ });
30
+
31
+ it('posts the bridge-shaped error message the parent already understands', () => {
32
+ const html = frame('console.log(1)');
33
+ expect(html).toContain("kind:'error'");
34
+ expect(html).toContain('v:1');
35
+ expect(html).toContain('window.parent.postMessage');
36
+ });
37
+
38
+ it('listens in the capture phase — a module script error does not bubble', () => {
39
+ expect(frame('x')).toMatch(/addEventListener\('error'[\s\S]*?\}, true\)/);
40
+ });
41
+
42
+ it('also covers an unhandled rejection at module scope', () => {
43
+ expect(frame('x')).toContain("addEventListener('unhandledrejection'");
44
+ });
45
+
46
+ it('still emits the app bundle and the import map', () => {
47
+ const html = frame('const marker = 42;');
48
+ expect(html).toContain('const marker = 42;');
49
+ expect(html).toContain('<script type="importmap">{"imports":{}}</script>');
50
+ });
51
+ });
@@ -129,6 +129,48 @@ function attr(v: string): string {
129
129
  return v.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
130
130
  }
131
131
 
132
+ /**
133
+ * Error reporter, installed BEFORE the app module so it can see the app fail
134
+ * during boot.
135
+ *
136
+ * A module-level failure — a link error like `does not provide an export named
137
+ * 'default'`, or a throw at module scope — happens before any component
138
+ * renders, so the kit's ErrorBoundary cannot exist yet, let alone catch it.
139
+ * Nothing was reported, the app never posted `ready`, and the parent could only
140
+ * sit out its 10s watchdog, retry once, and show a causeless "couldn't load the
141
+ * app" some twenty seconds later — the same blank wall for a typo, a dead
142
+ * chunk, or a real crash. Now the parent has the actual reason in milliseconds.
143
+ *
144
+ * `error` on window catches both cases: module link/parse errors surface there
145
+ * even though no user code ran. Capture phase, because a module script's error
146
+ * event does not bubble.
147
+ *
148
+ * It deliberately does NOT try to decide whether the app had already booted —
149
+ * `ready` goes to the (cross-origin) parent, so this document never sees it,
150
+ * and `window.parent.postMessage` cannot be wrapped to eavesdrop. The PARENT
151
+ * already tracks that (`everReadyRef` in app-sandbox) and is the right place to
152
+ * classify: pre-ready error = load failure, post-ready = runtime hiccup. So
153
+ * this reports faithfully and lets the parent judge.
154
+ */
155
+ const ERROR_REPORTER = `(function(){
156
+ var last = '';
157
+ function report(msg){
158
+ var text = String(msg || 'the app failed to start');
159
+ if (text === last) return; // a repeating error must not become a message loop
160
+ last = text;
161
+ try { window.parent.postMessage({ v:1, kind:'error', message: text }, '*'); } catch (_) {}
162
+ }
163
+ window.addEventListener('error', function(e){
164
+ // Subresource load failures (img/link) target an element, not window, and
165
+ // are not app failures — ignore them.
166
+ if (e && e.target && e.target !== window && e.target.tagName) return;
167
+ report((e && (e.message || (e.error && e.error.message))) || 'the app failed to start');
168
+ }, true);
169
+ window.addEventListener('unhandledrejection', function(e){
170
+ report((e && e.reason && (e.reason.message || e.reason)) || 'the app failed to start');
171
+ });
172
+ })();`;
173
+
132
174
  export function buildAppFrameHtml(opts: {
133
175
  /** The app's built module bundle (esbuild output; esbuild escapes any
134
176
  * `</script` inside string literals, so inlining it is safe). */
@@ -194,6 +236,7 @@ body{overflow:auto}`
194
236
  </head>
195
237
  <body class="bg-background text-foreground">
196
238
  <div id="root"></div>
239
+ <script>${ERROR_REPORTER}</script>
197
240
  <script type="module">${opts.bundleCode}</script>
198
241
  <script>${INSPECTOR}</script>
199
242
  </body>
@@ -100,6 +100,9 @@ export function AppSandbox({
100
100
  const apiBase = apiBaseOverride ?? (shareToken ? `/s/${shareToken}` : `/api/apps/${appId}`);
101
101
  const iframeRef = useRef<HTMLIFrameElement>(null);
102
102
  const [status, setStatus] = useState<Status>('loading');
103
+ /** Why the app failed, when the frame could tell us. Shown under the
104
+ * generic line so a load failure is diagnosable without the console. */
105
+ const [failReason, setFailReason] = useState<string | null>(null);
103
106
  const [height, setHeight] = useState(320);
104
107
  // The frame URL (ticket included), held in state so the iframe is RENDERED
105
108
  // with its src (keyed per attempt) — the element is created carrying the
@@ -239,10 +242,17 @@ export function AppSandbox({
239
242
  }
240
243
  if (m.kind === 'error') {
241
244
  cbRef.current.onError?.(m.message);
242
- // An error BEFORE the app ever became ready is a mount/boot crash (the
243
- // kit's ErrorBoundary posts it synchronously during the first render,
244
- // ahead of the ready signal) — a load failure, not a runtime hiccup.
245
- if (!everReadyRef.current) cbRef.current.onLoadFailure?.();
245
+ // An error BEFORE the app ever became ready is a mount/boot failure —
246
+ // the kit's ErrorBoundary for a render crash, or the frame's own
247
+ // reporter for a module-level link/parse error it could never catch.
248
+ // Either way the app is not coming up, so fail NOW with the reason
249
+ // instead of leaving the user on a spinner until the watchdog expires
250
+ // and then saying nothing useful.
251
+ if (!everReadyRef.current) {
252
+ setFailReason(m.message);
253
+ setStatus('error');
254
+ cbRef.current.onLoadFailure?.();
255
+ }
246
256
  return;
247
257
  }
248
258
  if (m.kind === 'select') {
@@ -299,6 +309,7 @@ export function AppSandbox({
299
309
  useEffect(() => {
300
310
  let cancelled = false;
301
311
  setStatus('loading');
312
+ setFailReason(null);
302
313
  setFrameSrc(null);
303
314
  everReadyRef.current = false;
304
315
  doFetch(`${apiBase}/frame-ticket`, { method: 'POST' })
@@ -358,7 +369,12 @@ export function AppSandbox({
358
369
  <div
359
370
  className={`flex items-center justify-center p-6 text-center text-sm text-destructive-ink ${isViewport ? 'h-full' : 'h-40'}`}
360
371
  >
361
- {isViewport ? 'Couldn’t load the app.' : 'Couldn’t load the app preview.'}
372
+ <div>
373
+ <div>{isViewport ? 'Couldn’t load the app.' : 'Couldn’t load the app preview.'}</div>
374
+ {failReason && (
375
+ <div className="mt-1 font-mono text-xs break-words opacity-80">{failReason}</div>
376
+ )}
377
+ </div>
362
378
  </div>
363
379
  )}
364
380
  {frameSrc !== null && (