@crossworks/share-ui 0.232.9 → 0.232.11
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 +3 -3
- package/src/app-frame-html.test.ts +51 -0
- package/src/app-frame-html.ts +43 -0
- package/src/app-sandbox.tsx +21 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/share-ui",
|
|
3
|
-
"version": "0.232.
|
|
3
|
+
"version": "0.232.11",
|
|
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.
|
|
36
|
-
"@mantle/content-core": "npm:@crossworks/content-core@0.232.
|
|
35
|
+
"@mantle/client-types": "npm:@crossworks/client-types@0.232.11",
|
|
36
|
+
"@mantle/content-core": "npm:@crossworks/content-core@0.232.11",
|
|
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
|
+
});
|
package/src/app-frame-html.ts
CHANGED
|
@@ -129,6 +129,48 @@ function attr(v: string): string {
|
|
|
129
129
|
return v.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
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>
|
package/src/app-sandbox.tsx
CHANGED
|
@@ -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
|
|
243
|
-
// kit's ErrorBoundary
|
|
244
|
-
//
|
|
245
|
-
|
|
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
|
-
|
|
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 && (
|