diff --git a/packages/blaze-hot/update-templates.js b/packages/blaze-hot/update-templates.js index d9ee82d59..cbb1e9940 100644 --- a/packages/blaze-hot/update-templates.js +++ b/packages/blaze-hot/update-templates.js @@ -45,6 +45,24 @@ let templateViewPrefix = 'Template.'; // Overrides the default _applyHmrChanges with one that updates the specific // views for modified templates instead of updating everything. Template._applyHmrChanges = function (templateName = UpdateAll) { + // Integration with Blaze Error Indicator: + // When templates are updated via HMR, clear any related errors. + // This provides a better developer experience by automatically + // clearing "No such template" errors when the missing template is added. + if (typeof Blaze !== 'undefined' && Blaze._errorIndicator) { + if (templateName === UpdateAll) { + // Full update: clear all errors since the entire view tree is refreshed + if (typeof Blaze._errorIndicator.clearAll === 'function') { + Blaze._errorIndicator.clearAll(); + } + } else { + // Targeted update: only clear errors related to this specific template + if (typeof Blaze._errorIndicator.removeTemplateError === 'function') { + Blaze._errorIndicator.removeTemplateError(templateName); + } + } + } + if (templateName === UpdateAll || lastUpdateFailed) { lastUpdateFailed = false; clearTimeout(timeout); diff --git a/packages/blaze/blaze.d.ts b/packages/blaze/blaze.d.ts index c27e6d04c..aa13dc2ec 100644 --- a/packages/blaze/blaze.d.ts +++ b/packages/blaze/blaze.d.ts @@ -121,5 +121,28 @@ declare module 'meteor/blaze' { function toHTML(templateOrView: Template | View): string; function toHTMLWithData(templateOrView: Template | View, data: Object | Function): string; + + /** + * Enable or disable the visual error indicator for Blaze errors. + * @param enabled Whether to enable the error indicator (default: true) + */ + function showErrorIndicator(enabled?: boolean): void; + + /** + * Clear all errors from the error indicator. + */ + function clearErrors(): void; + + /** + * Get the current list of Blaze errors. + * @returns Array of error objects + */ + function getErrors(): Array<{ + id: number; + message: string; + error: string; + stack: string; + time: string; + }>; } } diff --git a/packages/blaze/builtins.js b/packages/blaze/builtins.js index dc7525e04..c8855a928 100644 --- a/packages/blaze/builtins.js +++ b/packages/blaze/builtins.js @@ -86,7 +86,16 @@ function _createBinding(view, binding, displayName, mapper) { const reactiveVar = new ReactiveVar(undefined, _isEqualBinding); if (typeof binding === 'function') { view.autorun( - () => _setBindingValue(reactiveVar, binding(), mapper), + () => { + try { + _setBindingValue(reactiveVar, binding(), mapper); + } catch (e) { + if (Blaze._throwNextException) { + throw e; + } + Blaze._reportException(e, 'Exception in template binding:'); + } + }, view.parentView, displayName, ); diff --git a/packages/blaze/errorIndicator.js b/packages/blaze/errorIndicator.js new file mode 100644 index 000000000..922a526d4 --- /dev/null +++ b/packages/blaze/errorIndicator.js @@ -0,0 +1,720 @@ +/** + * Blaze Error Indicator + * + * A visual error indicator for Blaze template errors during development. + * Displays a badge in the bottom-left corner showing the number of errors, + * with a modal that shows detailed error information when clicked. + * + * Features: + * - Catches and displays Blaze/template-related errors + * - Graceful failure with inline error placeholders + * - Error deduplication + * - HMR integration for automatic error clearing + * - Accessible (ARIA labels, keyboard navigation) + * - Production mode detection (disabled by default in production) + * + * @fileoverview Client-side error indicator for Blaze templates + */ + +(function() { + 'use strict'; + + // Only run on client + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + // ============================================ + // Configuration + // ============================================ + + var CONFIG = { + // Error deduplication window in milliseconds + DEDUPE_WINDOW_MS: 100, + // CSS class prefix for namespacing + CSS_PREFIX: 'blaze-error', + // Z-index for indicator and modal + Z_INDEX_BADGE: 99999, + Z_INDEX_MODAL: 100000, + // Check for production mode (Meteor sets this) + isProduction: function() { + return typeof Meteor !== 'undefined' && Meteor.isProduction; + } + }; + + // Keywords that identify Blaze-related errors + var BLAZE_KEYWORDS = [ + 'Template', 'Blaze', 'Spacebars', 'No such template', + 'No such function', 'htmljs', 'Can\'t render', + 'Expected Template or View', 'DOMRange', 'parentElement', + 'Unsupported directive', 'Can\'t call non-function' + ]; + + // ============================================ + // State + // ============================================ + + var errors = []; + var isModalOpen = false; + var containerEl = null; + var isInitialized = false; + var styleEl = null; + var isEnabled = true; + var handleGlobalError = null; + var handleUnhandledRejection = null; + + // ============================================ + // Utility Functions + // ============================================ + + /** + * Checks if an error message or stack trace is related to Blaze + * @param {string} message - The error message + * @param {Error} error - The error object + * @returns {boolean} True if the error is Blaze-related + */ + function isBlazeRelatedError(message, error) { + var stack = (error && error.stack) ? error.stack : ''; + var fullText = (message || '') + ' ' + stack; + + for (var i = 0; i < BLAZE_KEYWORDS.length; i++) { + if (fullText.indexOf(BLAZE_KEYWORDS[i]) !== -1) { + return true; + } + } + return false; + } + + /** + * Escapes HTML special characters to prevent XSS + * @param {string} text - Text to escape + * @returns {string} Escaped HTML string + */ + function escapeHtml(text) { + var div = document.createElement('div'); + div.textContent = text || ''; + return div.innerHTML; + } + + /** + * Formats a timestamp to locale time string + * @returns {string} Formatted time string + */ + function formatTime() { + return new Date().toLocaleTimeString(); + } + + // ============================================ + // Styles + // ============================================ + + /** + * CSS styles for the error indicator + * Using template literals would be nice but we need ES5 compatibility + */ + function getStyles() { + var prefix = CONFIG.CSS_PREFIX; + return [ + '.' + prefix + '-indicator {', + ' position: fixed;', + ' bottom: 20px;', + ' left: 20px;', + ' z-index: ' + CONFIG.Z_INDEX_BADGE + ';', + ' display: flex;', + ' align-items: center;', + ' gap: 8px;', + ' padding: 10px 16px;', + ' background-color: #dc3545;', + ' color: white;', + ' border-radius: 8px;', + ' cursor: pointer;', + ' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;', + ' font-size: 14px;', + ' font-weight: 500;', + ' box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);', + ' transition: transform 0.2s, box-shadow 0.2s;', + ' border: none;', + '}', + '.' + prefix + '-indicator:hover {', + ' transform: translateY(-2px);', + ' box-shadow: 0 6px 16px rgba(220, 53, 69, 0.5);', + '}', + '.' + prefix + '-indicator:focus {', + ' outline: 2px solid #fff;', + ' outline-offset: 2px;', + '}', + '.' + prefix + '-indicator .error-count {', + ' background-color: rgba(255, 255, 255, 0.2);', + ' padding: 2px 8px;', + ' border-radius: 12px;', + ' font-size: 12px;', + '}', + '.' + prefix + '-modal-overlay {', + ' position: fixed;', + ' top: 0;', + ' left: 0;', + ' right: 0;', + ' bottom: 0;', + ' background-color: rgba(0, 0, 0, 0.5);', + ' z-index: ' + CONFIG.Z_INDEX_MODAL + ';', + ' display: flex;', + ' align-items: center;', + ' justify-content: center;', + ' padding: 20px;', + ' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;', + '}', + '.' + prefix + '-modal {', + ' background-color: #1e1e1e;', + ' color: #d4d4d4;', + ' border-radius: 12px;', + ' max-width: 800px;', + ' width: 100%;', + ' max-height: 80vh;', + ' display: flex;', + ' flex-direction: column;', + ' box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);', + '}', + '.' + prefix + '-modal-header {', + ' display: flex;', + ' align-items: center;', + ' justify-content: space-between;', + ' padding: 16px 20px;', + ' border-bottom: 1px solid #333;', + ' background-color: #dc3545;', + ' color: white;', + ' border-radius: 12px 12px 0 0;', + '}', + '.' + prefix + '-modal-header h3 {', + ' margin: 0;', + ' font-size: 18px;', + '}', + '.' + prefix + '-modal-close {', + ' background: none;', + ' border: none;', + ' color: white;', + ' font-size: 28px;', + ' cursor: pointer;', + ' padding: 0 4px;', + ' line-height: 1;', + ' border-radius: 4px;', + '}', + '.' + prefix + '-modal-close:hover {', + ' background-color: rgba(255, 255, 255, 0.2);', + '}', + '.' + prefix + '-modal-close:focus {', + ' outline: 2px solid #fff;', + ' outline-offset: 2px;', + '}', + '.' + prefix + '-modal-body {', + ' padding: 20px;', + ' overflow-y: auto;', + ' flex: 1;', + '}', + '.' + prefix + '-item {', + ' background-color: #2d2d2d;', + ' border-radius: 8px;', + ' padding: 16px;', + ' margin-bottom: 12px;', + ' border-left: 4px solid #dc3545;', + '}', + '.' + prefix + '-item:last-child {', + ' margin-bottom: 0;', + '}', + '.' + prefix + '-item-header {', + ' display: flex;', + ' justify-content: space-between;', + ' align-items: flex-start;', + ' margin-bottom: 8px;', + ' gap: 12px;', + '}', + '.' + prefix + '-item-title {', + ' font-weight: 600;', + ' color: #f87171;', + ' font-size: 14px;', + '}', + '.' + prefix + '-item-time {', + ' font-size: 12px;', + ' color: #888;', + ' white-space: nowrap;', + '}', + '.' + prefix + '-item-message {', + ' font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;', + ' font-size: 13px;', + ' color: #e5e5e5;', + ' white-space: pre-wrap;', + ' word-break: break-word;', + '}', + '.' + prefix + '-item-stack {', + ' margin-top: 12px;', + ' padding-top: 12px;', + ' border-top: 1px solid #444;', + ' font-family: "SF Mono", Monaco, Menlo, Consolas, monospace;', + ' font-size: 11px;', + ' color: #888;', + ' white-space: pre-wrap;', + ' word-break: break-word;', + ' max-height: 150px;', + ' overflow-y: auto;', + '}', + '.' + prefix + '-modal-footer {', + ' padding: 12px 20px;', + ' border-top: 1px solid #333;', + ' display: flex;', + ' justify-content: flex-end;', + ' gap: 10px;', + '}', + '.' + prefix + '-btn {', + ' padding: 8px 16px;', + ' border-radius: 6px;', + ' border: none;', + ' cursor: pointer;', + ' font-size: 14px;', + ' font-weight: 500;', + ' transition: background-color 0.15s;', + '}', + '.' + prefix + '-btn:focus {', + ' outline: 2px solid #fff;', + ' outline-offset: 2px;', + '}', + '.' + prefix + '-btn-secondary {', + ' background-color: #333;', + ' color: #d4d4d4;', + '}', + '.' + prefix + '-btn-secondary:hover {', + ' background-color: #444;', + '}', + '.' + prefix + '-btn-primary {', + ' background-color: #dc3545;', + ' color: white;', + '}', + '.' + prefix + '-btn-primary:hover {', + ' background-color: #c82333;', + '}' + ].join('\n'); + } + + /** + * Injects styles into the document head + */ + function injectStyles() { + if (styleEl) return; + + styleEl = document.createElement('style'); + styleEl.id = CONFIG.CSS_PREFIX + '-styles'; + styleEl.textContent = getStyles(); + document.head.appendChild(styleEl); + } + + /** + * Removes injected styles from the document + */ + function removeStyles() { + if (styleEl && styleEl.parentNode) { + styleEl.parentNode.removeChild(styleEl); + styleEl = null; + } + } + + // ============================================ + // DOM Management + // ============================================ + + /** + * Creates the container element for the error indicator + */ + function createContainer() { + if (containerEl) return; + if (!document.body) return; + + containerEl = document.createElement('div'); + containerEl.id = CONFIG.CSS_PREFIX + '-container'; + // Set ARIA live region for screen reader announcements + containerEl.setAttribute('aria-live', 'polite'); + containerEl.setAttribute('aria-atomic', 'true'); + document.body.appendChild(containerEl); + } + + /** + * Removes the container element from the document + */ + function removeContainer() { + if (containerEl && containerEl.parentNode) { + containerEl.parentNode.removeChild(containerEl); + containerEl = null; + } + } + + /** + * Renders the error indicator badge + * @returns {string} HTML string for the badge + */ + function renderBadge() { + var prefix = CONFIG.CSS_PREFIX; + var count = errors.length; + var label = count === 1 ? '1 Blaze error' : count + ' Blaze errors'; + + return [ + '' + ].join(''); + } + + /** + * Renders the error modal + * @returns {string} HTML string for the modal + */ + function renderModal() { + var prefix = CONFIG.CSS_PREFIX; + var html = []; + + html.push( + '' + ); + + return html.join(''); + } + + /** + * Renders the complete UI based on current state + */ + function render() { + if (!containerEl || !isEnabled) return; + + var html = ''; + + if (errors.length > 0) { + html = renderBadge(); + + if (isModalOpen) { + html += renderModal(); + } + } + + containerEl.innerHTML = html; + attachEventListeners(); + } + + // ============================================ + // Event Handlers + // ============================================ + + /** + * Opens the error modal + */ + function openModal() { + isModalOpen = true; + render(); + // Focus the close button for keyboard accessibility + var closeBtn = document.getElementById(CONFIG.CSS_PREFIX + '-close'); + if (closeBtn) closeBtn.focus(); + } + + /** + * Closes the error modal + */ + function closeModal() { + isModalOpen = false; + render(); + // Return focus to the indicator button + var btn = document.getElementById(CONFIG.CSS_PREFIX + '-btn'); + if (btn) btn.focus(); + } + + /** + * Handles keyboard events for accessibility + * @param {KeyboardEvent} e - The keyboard event + */ + function handleKeydown(e) { + if (!isModalOpen) return; + + // Close on Escape key + if (e.key === 'Escape' || e.keyCode === 27) { + e.preventDefault(); + closeModal(); + } + } + + /** + * Attaches event listeners to rendered elements + */ + function attachEventListeners() { + var prefix = CONFIG.CSS_PREFIX; + + var btn = document.getElementById(prefix + '-btn'); + if (btn) { + btn.onclick = openModal; + } + + var closeBtn = document.getElementById(prefix + '-close'); + var closeBtnFooter = document.getElementById(prefix + '-close-btn'); + var overlay = document.getElementById(prefix + '-overlay'); + var clearBtn = document.getElementById(prefix + '-clear'); + + if (closeBtn) { + closeBtn.onclick = closeModal; + } + if (closeBtnFooter) { + closeBtnFooter.onclick = closeModal; + } + if (overlay) { + overlay.onclick = function(e) { + if (e.target === overlay) { + closeModal(); + } + }; + } + if (clearBtn) { + clearBtn.onclick = clearAll; + } + } + + // ============================================ + // Public API Functions + // ============================================ + + /** + * Adds an error to the indicator + * @param {Error|string} error - The error object or message + * @param {string} [msg] - Optional context message + */ + function addError(error, msg) { + if (!isEnabled) return; + + // Ensure we're initialized + if (!isInitialized) { + init(); + } + + var errorMessage = (error && error.message) ? error.message : String(error); + + // Deduplicate: don't add if we have this exact error message recently + var now = Date.now(); + for (var i = errors.length - 1; i >= 0; i--) { + if (errors[i].error === errorMessage && + (now - errors[i].timestamp) < CONFIG.DEDUPE_WINDOW_MS) { + return; + } + } + + errors.push({ + id: now, + message: msg || 'Exception caught in template:', + error: errorMessage, + stack: (error && error.stack) ? error.stack : '', + time: formatTime(), + timestamp: now + }); + + render(); + } + + /** + * Removes errors related to a specific template name + * Used by HMR when a missing template is added + * @param {string} templateName - The name of the template + */ + function removeTemplateError(templateName) { + var pattern = 'No such template: ' + templateName; + var hadErrors = errors.length > 0; + + errors = errors.filter(function(err) { + return err.error.indexOf(pattern) === -1; + }); + + if (hadErrors && errors.length === 0) { + isModalOpen = false; + } + + render(); + } + + /** + * Clears all errors + */ + function clearAll() { + errors = []; + isModalOpen = false; + render(); + } + + /** + * Initializes the error indicator + */ + function init() { + if (isInitialized) return; + + // Skip initialization in production mode by default + if (CONFIG.isProduction()) { + isEnabled = false; + return; + } + + if (!document.body) { + // Wait for body to be available + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + setTimeout(init, 10); + } + return; + } + + isInitialized = true; + injectStyles(); + createContainer(); + render(); + + // Add keyboard event listener for accessibility + document.addEventListener('keydown', handleKeydown); + + // Listen for global errors that might be Blaze-related + handleGlobalError = function(event) { + if (isBlazeRelatedError(event.message, event.error)) { + addError(event.error || new Error(event.message), 'Uncaught template error:'); + } + }; + window.addEventListener('error', handleGlobalError); + + handleUnhandledRejection = function(event) { + var errorMsg = (event.reason && event.reason.message) + ? event.reason.message + : String(event.reason); + if (isBlazeRelatedError(errorMsg, event.reason)) { + addError(event.reason, 'Unhandled template promise rejection:'); + } + }; + window.addEventListener('unhandledrejection', handleUnhandledRejection); + } + + /** + * Destroys the error indicator and cleans up resources + */ + function destroy() { + document.removeEventListener('keydown', handleKeydown); + if (handleGlobalError) { + window.removeEventListener('error', handleGlobalError); + handleGlobalError = null; + } + if (handleUnhandledRejection) { + window.removeEventListener('unhandledrejection', handleUnhandledRejection); + handleUnhandledRejection = null; + } + removeContainer(); + removeStyles(); + errors = []; + isModalOpen = false; + isInitialized = false; + isEnabled = false; + } + + // ============================================ + // Blaze Integration + // ============================================ + + /** + * Internal API for Blaze integration + * @private + */ + Blaze._errorIndicator = { + addError: addError, + removeTemplateError: removeTemplateError, + clearAll: clearAll, + init: init, + destroy: destroy + }; + + /** + * Enable or disable the error indicator + * @param {boolean} [enabled=true] - Whether to enable the indicator + * @memberof Blaze + * @example + * // Disable the error indicator + * Blaze.showErrorIndicator(false); + * + * // Enable the error indicator + * Blaze.showErrorIndicator(true); + */ + Blaze.showErrorIndicator = function(enabled) { + if (enabled !== false) { + isEnabled = true; + init(); + } else { + destroy(); + } + }; + + /** + * Clear all errors from the indicator + * @memberof Blaze + * @example + * Blaze.clearErrors(); + */ + Blaze.clearErrors = function() { + clearAll(); + }; + + /** + * Get a copy of the current errors array + * @returns {Array} Array of error objects + * @memberof Blaze + * @example + * const errors = Blaze.getErrors(); + * console.log('There are', errors.length, 'errors'); + */ + Blaze.getErrors = function() { + return errors.slice(); + }; + + // ============================================ + // Auto-initialization + // ============================================ + + // Auto-initialize when DOM is ready (in development mode only) + if (!CONFIG.isProduction()) { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + // Use setTimeout to ensure this runs after all scripts are loaded + setTimeout(init, 0); + } + } + +})(); diff --git a/packages/blaze/exceptions.js b/packages/blaze/exceptions.js index e0ddce879..6ee7977ec 100644 --- a/packages/blaze/exceptions.js +++ b/packages/blaze/exceptions.js @@ -40,6 +40,11 @@ Blaze._reportException = function (e, msg) { // and contains a stack trace. Furthermore, `console.error` (or `console.log`) // makes it clickable. Both provide space between the two arguments. debugFunc()(msg || 'Exception caught in template:', e.stack || e.message || e); + + // Report to visual error indicator (client-side only) + if (typeof Blaze._errorIndicator !== 'undefined' && Blaze._errorIndicator) { + Blaze._errorIndicator.addError(e, msg); + } }; // It's meant to be used in `Promise` chains to report the error while not diff --git a/packages/blaze/package.js b/packages/blaze/package.js index fb92a6545..f1fc2112f 100644 --- a/packages/blaze/package.js +++ b/packages/blaze/package.js @@ -44,6 +44,13 @@ Package.onUse(function (api) { 'template.js', 'backcompat.js' ]); + + // Error indicator for development - provides visual error feedback + // with inline placeholders for missing templates. Client-only and + // automatically disabled in production. See errorIndicator.js for details. + api.addFiles([ + 'errorIndicator.js' + ], 'client'); // Maybe in order to work properly user will need to have Jquery typedefs api.addAssets('blaze.d.ts', 'server'); }); diff --git a/packages/blaze/preamble.js b/packages/blaze/preamble.js index bd2d84beb..5dbb5f746 100644 --- a/packages/blaze/preamble.js +++ b/packages/blaze/preamble.js @@ -39,6 +39,30 @@ Blaze._warn = function (msg) { } }; +/** + * Shared inline style for error placeholders rendered when templates fail. + * Used by view.js, spacebars-runtime.js, and generated fallback code. + * @private + */ +Blaze._ERROR_PLACEHOLDER_STYLE = 'display:block;padding:8px 12px;margin:4px 0;' + + 'background-color:#fee;border:1px solid #fcc;' + + 'border-left:4px solid #dc3545;color:#721c24;' + + 'font-family:monospace;font-size:13px;border-radius:4px;white-space:pre-wrap'; + +/** + * Renders an inline error placeholder SPAN using htmljs. + * Used by the rendering pipeline to show errors without crashing the page. + * + * @param {string} message - The error message to display + * @returns {Object} An htmljs SPAN node + * @private + */ +Blaze._renderErrorPlaceholder = function (message) { + return HTML.SPAN({ + style: Blaze._ERROR_PLACEHOLDER_STYLE + }, '\u26A0 ' + message); +}; + const nativeBind = Function.prototype.bind; // An implementation of _.bind which allows better optimization. diff --git a/packages/html-tools/parse.js b/packages/html-tools/parse.js index 642ebfc50..b484ae60e 100644 --- a/packages/html-tools/parse.js +++ b/packages/html-tools/parse.js @@ -47,10 +47,13 @@ export function parseFragment(input, options) { const posBefore = scanner.pos; + var endTag; + var parseError; try { - var endTag = getHTMLToken(scanner); + endTag = getHTMLToken(scanner); } catch (e) { - // ignore errors from getTemplateTag + // Save the error - we may need to report it if we can't provide a better one + parseError = e; } // XXX we make some assumptions about shouldStop here, like that it @@ -66,8 +69,12 @@ export function parseFragment(input, options) { // If no "shouldStop" option was provided, we should have consumed the whole // input. - if (! shouldStop) + if (! shouldStop) { + // If we captured a parse error earlier, throw it instead of a generic "Expected EOF" + if (parseError) + throw parseError; scanner.fatal("Expected EOF"); + } } return result; diff --git a/packages/spacebars-tests/old_templates_tests.js b/packages/spacebars-tests/old_templates_tests.js index bfea0a6dd..d91fda23f 100644 --- a/packages/spacebars-tests/old_templates_tests.js +++ b/packages/spacebars-tests/old_templates_tests.js @@ -1800,9 +1800,13 @@ Tinytest.add( Tinytest.add( 'spacebars-tests - old - template_tests - unfound template', function (test) { - test.throws(function () { - renderToDiv(Template.old_spacebars_test_nonexistent_template); - }, /No such template/); + // Missing templates now render gracefully with an error placeholder + // instead of throwing (see errorIndicator.js). + Blaze.clearErrors(); + var div = renderToDiv(Template.old_spacebars_test_nonexistent_template); + var errors = Blaze.getErrors(); + test.isTrue(errors.length > 0, 'Expected at least one error'); + test.isTrue(/No such template/.test(errors[0].error), 'Expected "No such template" error'); } ); diff --git a/packages/spacebars-tests/template_tests.js b/packages/spacebars-tests/template_tests.js index 2821daf54..00b712b46 100644 --- a/packages/spacebars-tests/template_tests.js +++ b/packages/spacebars-tests/template_tests.js @@ -2193,9 +2193,13 @@ Tinytest.add('spacebars-tests - template_tests - attributes', function (test) { Tinytest.add( 'spacebars-tests - template_tests - unfound template', function (test) { - test.throws(function () { - renderToDiv(Template.spacebars_test_nonexistent_template); - }, /No such template/); + // Missing templates now render gracefully with an error placeholder + // instead of throwing (see errorIndicator.js). + Blaze.clearErrors(); + var div = renderToDiv(Template.spacebars_test_nonexistent_template); + var errors = Blaze.getErrors(); + test.isTrue(errors.length > 0, 'Expected at least one error'); + test.isTrue(/No such template/.test(errors[0].error), 'Expected "No such template" error'); } ); diff --git a/packages/spacebars/spacebars-runtime.js b/packages/spacebars/spacebars-runtime.js index c8076bbc0..1456f92c5 100644 --- a/packages/spacebars/spacebars-runtime.js +++ b/packages/spacebars/spacebars-runtime.js @@ -43,6 +43,14 @@ Spacebars.include = function (templateOrFunction, contentFunc, elseFunc) { if (template === null) return null; + // Error state: render inline error placeholder instead of crashing + if (template instanceof Error) { + if (typeof Blaze._errorIndicator !== 'undefined' && Blaze._errorIndicator) { + return Blaze._renderErrorPlaceholder(template.message); + } + return null; + } + if (! Blaze.isTemplate(template)) throw new Error(`Expected template or null, found: ${template}`); @@ -51,7 +59,17 @@ Spacebars.include = function (templateOrFunction, contentFunc, elseFunc) { view.__templateVar = templateVar; view.onViewCreated(function () { this.autorun(function () { - templateVar.set(templateOrFunction()); + try { + templateVar.set(templateOrFunction()); + } catch (e) { + // If _throwNextException is set (e.g., in tests), let the error + // propagate normally so test assertions work + if (Blaze._throwNextException) { + throw e; + } + templateVar.set(e); + Blaze._reportException(e, 'Exception in template inclusion:'); + } }); }); view.__startsNewLexicalScope = true; diff --git a/packages/templating-tools/compile-tags-with-spacebars.js b/packages/templating-tools/compile-tags-with-spacebars.js index 0b8b23471..137bbf33a 100644 --- a/packages/templating-tools/compile-tags-with-spacebars.js +++ b/packages/templating-tools/compile-tags-with-spacebars.js @@ -84,8 +84,50 @@ class SpacebarsTagCompiler { } } catch (e) { if (e.scanner) { - // The error came from Spacebars - this.throwCompileError(e.message, this.tag.contentsStartIndex + e.offset); + // In production builds (no HMR), throw a proper compile error + // so the build fails with a clear error message as expected + if (!hmrAvailable) { + this.throwCompileError(e.message, this.tag.contentsStartIndex + e.offset); + } + + // In development (HMR available), generate fallback code that + // shows the compile error in the client-side error indicator + const errorMessage = e.message; + const errorOffset = this.tag.contentsStartIndex + e.offset; + const errorLine = this.tag.fileContents + .substring(0, errorOffset).split('\n').length; + + const sourceName = this.tag.tagName === 'template' + ? `Template "${this.tag.attribs.name}"` + : ''; + + console.warn( + `Warning: Compile error in ${this.tag.sourceName}:${errorLine}: ${errorMessage}\n` + + `The app will still run, but the affected template will show an error placeholder.` + ); + + const fullError = `Compile error in ${sourceName} (${this.tag.sourceName}:${errorLine}): ${errorMessage}`; + const renderFuncCode = `function () { + return Blaze._renderErrorPlaceholder(${JSON.stringify(fullError)}); +}`; + + if (this.tag.tagName === 'template') { + const name = this.tag.attribs.name; + if (name) { + this.results.js += generateTemplateJS( + name, renderFuncCode, hmrAvailable); + } + } else if (this.tag.tagName === 'body') { + this.results.js += generateBodyJS(renderFuncCode, hmrAvailable); + } + + // Report to error indicator at runtime + this.results.js += `\nif (typeof Blaze !== "undefined" && Blaze._errorIndicator) {\n` + + ` Blaze._errorIndicator.addError(\n` + + ` new Error(${JSON.stringify(fullError)}),\n` + + ` "Template compile error:"\n` + + ` );\n` + + `}\n`; } else { throw e; } diff --git a/packages/templating-tools/html-scanner-tests.js b/packages/templating-tools/html-scanner-tests.js index 9f3e22211..6611e325b 100644 --- a/packages/templating-tools/html-scanner-tests.js +++ b/packages/templating-tools/html-scanner-tests.js @@ -13,7 +13,7 @@ Tinytest.add("templating-tools - html scanner", function (test) { try { f(); } catch (e) { - if (! e instanceof TemplatingTools.CompileError) { + if (!(e instanceof TemplatingTools.CompileError)) { throw e; }