From 6adff494082a4de684be4b2e966e99993e8eb465 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 21:08:56 +0000 Subject: [PATCH] Canvas: a render that fails or stalls says so and never blocks the next In the preview frame, anything that threw after the old view was destroyed - a model that does not apply, a renderer that fails on a value - left the canvas blank and `rendering` true for ever: every later edit was queued behind it and never shown, with no word why. Now every render ends, in a view or in an error on the canvas and in the designer's status line; a view creation UI5 does not answer within 20 seconds is reported too; a model-only update counts as a render, so the status clears. UI5's own error log (an invalid property value, a binding it cannot parse) reaches the status line as a warning. The status line offers to reload the canvas. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Wdz84WSKQE9qRrS2Gu9dWp --- src/preview/init.js | 96 +++++++++++++++++++++++++++++++------- src/ui/preview.js | 16 +++++-- styles.css | 2 + test/e2e/designer.spec.mjs | 15 ++++++ 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/src/preview/init.js b/src/preview/init.js index 130175b..abbbbf0 100644 --- a/src/preview/init.js +++ b/src/preview/init.js @@ -113,31 +113,78 @@ sap.ui.define([ window.addEventListener('scroll', function () { if (tools) placeTools(); }, true); window.addEventListener('resize', function () { if (tools) placeTools(); }); + // Every render ends - in a view on the canvas, or in an error the + // designer shows - and releases the next one. Whatever throws on the way + // (the view's creation, a model, the placing: a renderer that fails on a + // value) is reported, never swallowed: a render that got stuck used to + // leave the canvas blank and every later edit unseen. + var RENDER_TIMEOUT = 20000; + var renderSeq = 0; function render(msg) { if (rendering) { pending = msg; return; } if (msg.xml === lastXml && view) { - view.getModel().setData(msg.model); - highlight(false); + try { + view.getModel().setData(msg.model); + highlight(false); + showError(''); + post({ type: 'rendered' }); // the view on the canvas is the design's again + } catch (err) { + fail('The model does not apply', err); + } return; } rendering = true; viewSeq += 1; - XMLView.create({ id: 'dsg' + (viewSeq % 2), definition: msg.xml, controller: new PreviewController() }).then(function (v) { - if (view) view.destroy(); - view = v; - lastXml = msg.xml; - v.setModel(new JSONModel(msg.model)); - v.placeAt('content'); - showError(''); - post({ type: 'rendered' }); - setTimeout(function () { highlight(false); }, 50); - }, function (err) { - showError('The view does not render: ' + (err && err.message ? err.message : err)); - post({ type: 'error', message: String(err && err.message ? err.message : err) }); - }).then(function () { + var seq = ++renderSeq; + var done = false; + function finish() { + if (done) return; + done = true; rendering = false; if (pending) { var next = pending; pending = null; render(next); } - }); + } + function fail(what, err) { + var text = what + ': ' + (err && err.message ? err.message : err); + showError(text); + post({ type: 'error', message: text }); + } + // UI5 that never answers (a control module the CDN does not deliver) + // would otherwise hold the canvas for ever + setTimeout(function () { + if (seq === renderSeq && !done) { + fail('The view is still loading after ' + (RENDER_TIMEOUT / 1000) + ' seconds', 'a control may not be loading - check the network, or reload the canvas'); + finish(); + } + }, RENDER_TIMEOUT); + var promise; + try { + promise = XMLView.create({ id: 'dsg' + (viewSeq % 2), definition: msg.xml, controller: new PreviewController() }); + } catch (err) { + fail('The view does not render', err); + finish(); + return; + } + promise.then(function (v) { + if (done) { v.destroy(); return; } // timed out meanwhile: the next render owns the canvas + try { + v.setModel(new JSONModel(msg.model)); + if (view) view.destroy(); + view = v; + lastXml = msg.xml; + v.placeAt('content'); + showError(''); + post({ type: 'rendered' }); + setTimeout(function () { highlight(false); }, 50); + } catch (err) { + // the old view is gone or half replaced: say so, the next edit + // renders anew + view = null; + lastXml = null; + fail('The view does not render', err); + } + }, function (err) { + fail('The view does not render', err); + }).then(finish, function (err) { fail('The view does not render', err); finish(); }); } // the metadata of a control class, the shape tools/ui5-metadata.mjs @@ -249,6 +296,23 @@ sap.ui.define([ post({ type: 'key', key: e.key, ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey, alt: e.altKey }); }); + // what UI5 logs as an error (an invalid value for a property, a binding + // it cannot parse, a renderer that gave up) reaches the designer too - + // the canvas may look fine or empty and nobody knows why + sap.ui.require(['sap/base/Log'], function (Log) { + var seen = {}; + Log.addLogListener({ + onLogEntry: function (entry) { + if (entry.level > Log.Level.ERROR) return; + var text = String(entry.message || ''); + if (/Parameters: The following parameters|could not be loaded from|sap-ui-version\.json/.test(text)) return; // local trees, not the view + if (seen[text]) return; + seen[text] = true; + post({ type: 'ui5log', message: text.slice(0, 300) }); + }, + }); + }); + // an error after the view was created - in a renderer, a formatter, an // event - would otherwise end in the console only window.addEventListener('error', function (e) { diff --git a/src/ui/preview.js b/src/ui/preview.js index 4d6e9b0..46cac4b 100644 --- a/src/ui/preview.js +++ b/src/ui/preview.js @@ -4,7 +4,7 @@ import { previewModel, findNode, shiftNode, duplicateNode, removeNode, walk, typ import { toXml, namespaceUri } from '../xml.js'; import { getControl, registerControl, runtimeDef, parseClassName, splitType } from '../catalog.js'; import { state, select, commit, labelOfSelection, emitChange } from './store.js'; -import { $, toast } from './dom.js'; +import { $, h, toast } from './dom.js'; import { handleDrop } from './edit.js'; import { onKey } from './keys.js'; @@ -24,10 +24,13 @@ export function loadPreview() { $('#preview').src = previewUrl(); } -// the line over the canvas: what UI5 is doing, or what went wrong -function previewStatus(text, level) { +// the line over the canvas: what UI5 is doing, or what went wrong - +// `reload` adds the way out of a canvas that is stuck +function previewStatus(text, level, reload) { const el = $('#previewStatus'); - el.textContent = text || ''; + el.replaceChildren(text || ''); + if (text && reload) el.append(h('button', { class: 'link', title: 'Reload the canvas', onclick: () => loadPreview() }, 'Reload canvas')); + el.title = text || ''; el.className = `preview-status${text ? '' : ' hidden'}${level ? ` ${level}` : ''}`; } @@ -116,8 +119,11 @@ window.addEventListener('message', (e) => { onKey({ key: msg.key, ctrlKey: msg.ctrl, metaKey: false, shiftKey: msg.shift, altKey: msg.alt, preventDefault() {}, target: document.body }); } else if (msg.type === 'error') { document.body.dataset.previewError = msg.message; - previewStatus(msg.boot ? 'UI5 did not load - see the canvas' : `The view ${msg.runtime ? 'failed at runtime' : 'does not render'}: ${msg.message}`, 'error'); + previewStatus(msg.boot ? 'UI5 did not load - see the canvas' : msg.runtime ? `The view failed at runtime: ${msg.message}` : msg.message, 'error', true); count(); + } else if (msg.type === 'ui5log') { + // UI5's own error: the view may show, but not as meant + if (!document.body.dataset.previewError) previewStatus(`UI5 reports: ${msg.message}`, 'warn', true); } else if (msg.type === 'rendered') { delete document.body.dataset.previewError; previewStatus(''); diff --git a/styles.css b/styles.css index 6e46d35..2ed5c20 100644 --- a/styles.css +++ b/styles.css @@ -173,6 +173,8 @@ button.cmd { display: flex; gap: 10px; align-items: center; min-width: 210px; ju .canvas-pane { display: flex; justify-content: center; padding: 14px; min-height: 0; min-width: 0; background: var(--panel-2); position: relative; } .preview-status { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); z-index: 3; max-width: calc(100% - 40px); padding: 5px 12px; border-radius: 8px; font-size: 12px; background: var(--panel); color: var(--muted); border: 1px solid var(--line); box-shadow: 0 2px 8px rgba(0,0,0,.12); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .preview-status.error { color: var(--error); border-color: var(--error); } +.preview-status.warn { color: var(--warn); border-color: var(--warn); } +.preview-status button.link { margin-left: 10px; font-size: 12px; } .device-frame { width: 100%; height: 100%; max-width: 100%; transition: width .15s; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 0 0.125rem 0 rgba(34,53,72,.15), 0 0.5rem 1rem 0 rgba(34,53,72,.15); } .device-frame iframe { width: 100%; height: 100%; border: 0; display: block; } .code-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; border-left: 1px solid var(--line); background: var(--panel); } diff --git a/test/e2e/designer.spec.mjs b/test/e2e/designer.spec.mjs index 0383247..1c1ff92 100644 --- a/test/e2e/designer.spec.mjs +++ b/test/e2e/designer.spec.mjs @@ -301,3 +301,18 @@ test('a list inside a bound list repeats a table field of the row', async ({ des expect(abap).toMatch(/n = `items` +v = `\{NODES\}`/); await expect(preview(page).getByText('Second child')).toBeVisible(); }); + +test('a view UI5 cannot render says why on the canvas, and the next edit renders again', async ({ designer: page }) => { + const before = await page.evaluate(() => Number(document.body.dataset.renders || 0)); + await page.evaluate(() => window.designer.commit((d) => { + d.root.aggs.content[0].aggs.content.push({ id: 'nbroken1', type: 'NoSuchControl', props: {}, events: {}, aggs: {} }); + })); + await expect(page.locator('#previewStatus')).toContainText('does not render'); + await expect(page.locator('#previewStatus')).toContainText('NoSuchControl'); + await expect(page.locator('#previewStatus')).toHaveClass(/error/); + // the way back: undo, and the canvas renders again - no render stays stuck + await page.locator('#btnUndo').click(); + await expect.poll(() => page.evaluate(() => Number(document.body.dataset.renders || 0))).toBeGreaterThan(before + 1); + await expect(page.locator('#previewStatus')).toBeHidden(); + await expect(preview(page).getByText('Customer data')).toBeVisible(); +});