Repository navigation
[iFrame] Add the srcdoc tip calculator example to the iframe catalog - #3008
josemontespg wants to merge 1 commit into
Conversation
e7b42bf to
a638cb2
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces a new WebAppFrameSrcdoc tip calculator example along with corresponding unit and end-to-end tests to verify its behavior through the sandbox proxy. Feedback on the changes highlights a potential asynchronous race condition in the tip calculator's script when rapidly recalculating values, which could lead to stale formatted totals overwriting newer ones. A code suggestion is provided to track the active call ID and ensure only the latest result is applied.
| "additionalProperties": false | ||
| } | ||
| }, | ||
| "htmlContent": "<!doctype html>\n<html>\n <head>\n <meta charset=\"utf-8\" />\n <style>\n body { font-family: sans-serif; margin: 16px; }\n td { padding: 4px 16px 4px 0; }\n td:last-child { text-align: right; }\n .total td { border-top: 1px solid #999; font-weight: bold; }\n button { margin-top: 12px; }\n </style>\n </head>\n <body>\n <table>\n <tr><td>Bill</td><td id=\"amount\"></td></tr>\n <tr><td>Tip (<span id=\"percent\"></span>%)</td><td id=\"tip\"></td></tr>\n <tr class=\"total\"><td>Total</td><td id=\"total\">Waiting for the host...</td></tr>\n </table>\n <button id=\"save\" disabled>Save receipt</button>\n <script>\n var port = null;\n var bill = { amount: 0, tipPercent: 0, total: 0 };\n var nextCallId = 1;\n\n function toNumber(value) {\n var number = Number(value);\n return isNaN(number) ? 0 : number;\n }\n\n function cents(value) {\n return Math.round(value * 100) / 100;\n }\n\n function recalculate() {\n var tip = cents(bill.amount * bill.tipPercent / 100);\n bill.total = cents(bill.amount + tip);\n document.getElementById('amount').textContent = bill.amount.toFixed(2);\n document.getElementById('percent').textContent = bill.tipPercent;\n document.getElementById('tip').textContent = tip.toFixed(2);\n // \"total\" is listed in mutableData, so the host writes it back to /bill/total.\n port.postMessage({ type: 'a2ui_data_model_change', key: 'total', value: bill.total });\n // formatCurrency is listed in allowedFunctions, so the host evaluates it with the\n // surface catalog and answers with a2ui_function_result.\n port.postMessage({\n type: 'a2ui_function_call',\n call: 'formatCurrency',\n callId: 'total-' + nextCallId++,\n args: { value: bill.total, currency: 'USD', decimals: 2 }\n });\n port.postMessage({ type: 'a2ui_size_changed', height: document.body.scrollHeight + 32 });\n }\n\n function onPortMessage(event) {\n var msg = event.data || {};\n if (msg.type === 'a2ui_data_model_update' && (msg.key === 'amount' || msg.key === 'tipPercent')) {\n bill[msg.key] = toNumber(msg.value);\n recalculate();\n } else if (msg.type === 'a2ui_function_result' && msg.status === 'success') {\n document.getElementById('total').textContent = msg.result;\n // \"totalLabel\" is mutable too, so the sibling Text component shows the same string.\n port.postMessage({ type: 'a2ui_data_model_change', key: 'totalLabel', value: msg.result });\n }\n }\n\n // Only the handshake travels over the ambient window channel. Everything after it uses\n // the MessagePort transferred with a2ui_app_frame_init.\n window.addEventListener('message', function (event) {\n if (event.source !== window.parent) return;\n var msg = event.data || {};\n if (msg.type !== 'a2ui_app_frame_init' || !event.ports || !event.ports[0]) return;\n port = event.ports[0];\n port.onmessage = onPortMessage;\n var initial = (msg.value && msg.value.initialData) || {};\n bill.amount = toNumber(initial.amount);\n bill.tipPercent = toNumber(initial.tipPercent);\n document.getElementById('save').disabled = false;\n recalculate();\n });\n\n document.getElementById('save').addEventListener('click', function () {\n // \"receipt_saved\" is listed in allowedEvents, so the host forwards it to the agent.\n port.postMessage({ type: 'a2ui_action', action: 'receipt_saved', data: bill });\n });\n\n window.parent.postMessage({ type: 'a2ui_app_frame_ready' }, '*');\n </script>\n </body>\n</html>\n" |
There was a problem hiding this comment.
There is a potential asynchronous race condition in the tip calculator's script. When the user rapidly changes the bill amount or tip percentage, multiple recalculate() calls are triggered, each sending an asynchronous formatCurrency function call to the host. Because these responses can arrive out of order, a stale formatted total might overwrite a newer one, leading to incorrect UI states.
To prevent this, we should track the activeCallId of the latest request and only apply the result if the incoming msg.callId matches the active one.
| "htmlContent": "<!doctype html>\n<html>\n <head>\n <meta charset=\"utf-8\" />\n <style>\n body { font-family: sans-serif; margin: 16px; }\n td { padding: 4px 16px 4px 0; }\n td:last-child { text-align: right; }\n .total td { border-top: 1px solid #999; font-weight: bold; }\n button { margin-top: 12px; }\n </style>\n </head>\n <body>\n <table>\n <tr><td>Bill</td><td id=\"amount\"></td></tr>\n <tr><td>Tip (<span id=\"percent\"></span>%)</td><td id=\"tip\"></td></tr>\n <tr class=\"total\"><td>Total</td><td id=\"total\">Waiting for the host...</td></tr>\n </table>\n <button id=\"save\" disabled>Save receipt</button>\n <script>\n var port = null;\n var bill = { amount: 0, tipPercent: 0, total: 0 };\n var nextCallId = 1;\n\n function toNumber(value) {\n var number = Number(value);\n return isNaN(number) ? 0 : number;\n }\n\n function cents(value) {\n return Math.round(value * 100) / 100;\n }\n\n function recalculate() {\n var tip = cents(bill.amount * bill.tipPercent / 100);\n bill.total = cents(bill.amount + tip);\n document.getElementById('amount').textContent = bill.amount.toFixed(2);\n document.getElementById('percent').textContent = bill.tipPercent;\n document.getElementById('tip').textContent = tip.toFixed(2);\n // \"total\" is listed in mutableData, so the host writes it back to /bill/total.\n port.postMessage({ type: 'a2ui_data_model_change', key: 'total', value: bill.total });\n // formatCurrency is listed in allowedFunctions, so the host evaluates it with the\n // surface catalog and answers with a2ui_function_result.\n port.postMessage({\n type: 'a2ui_function_call',\n call: 'formatCurrency',\n callId: 'total-' + nextCallId++,\n args: { value: bill.total, currency: 'USD', decimals: 2 }\n });\n port.postMessage({ type: 'a2ui_size_changed', height: document.body.scrollHeight + 32 });\n }\n\n function onPortMessage(event) {\n var msg = event.data || {};\n if (msg.type === 'a2ui_data_model_update' && (msg.key === 'amount' || msg.key === 'tipPercent')) {\n bill[msg.key] = toNumber(msg.value);\n recalculate();\n } else if (msg.type === 'a2ui_function_result' && msg.status === 'success') {\n document.getElementById('total').textContent = msg.result;\n // \"totalLabel\" is mutable too, so the sibling Text component shows the same string.\n port.postMessage({ type: 'a2ui_data_model_change', key: 'totalLabel', value: msg.result });\n }\n }\n\n // Only the handshake travels over the ambient window channel. Everything after it uses\n // the MessagePort transferred with a2ui_app_frame_init.\n window.addEventListener('message', function (event) {\n if (event.source !== window.parent) return;\n var msg = event.data || {};\n if (msg.type !== 'a2ui_app_frame_init' || !event.ports || !event.ports[0]) return;\n port = event.ports[0];\n port.onmessage = onPortMessage;\n var initial = (msg.value && msg.value.initialData) || {};\n bill.amount = toNumber(initial.amount);\n bill.tipPercent = toNumber(initial.tipPercent);\n document.getElementById('save').disabled = false;\n recalculate();\n });\n\n document.getElementById('save').addEventListener('click', function () {\n // \"receipt_saved\" is listed in allowedEvents, so the host forwards it to the agent.\n port.postMessage({ type: 'a2ui_action', action: 'receipt_saved', data: bill });\n });\n\n window.parent.postMessage({ type: 'a2ui_app_frame_ready' }, '*');\n </script>\n </body>\n</html>\n" | |
| "htmlContent": "\u003c!doctype html\u003e\n\u003chtml\u003e\n \u003chead\u003e\n \u003cmeta charset=\"utf-8\" /\u003e\n \u003cstyle\u003e\n body { font-family: sans-serif; margin: 16px; }\n td { padding: 4px 16px 4px 0; }\n td:last-child { text-align: right; }\n .total td { border-top: 1px solid #999; font-weight: bold; }\n button { margin-top: 12px; }\n \u003c/style\u003e\n \u003c/head\u003e\n \u003cbody\u003e\n \u003ctable\u003e\n \u003ctr\u003e\u003ctd\u003eBill\u003c/td\u003e\u003ctd id=\"amount\"\u003e\u003c/td\u003e\u003c/tr\u003e\n \u003ctr\u003e\u003ctd\u003eTip (\u003cspan id=\"percent\"\u003e\u003c/span\u003e%)\u003c/td\u003e\u003ctd id=\"tip\"\u003e\u003c/td\u003e\u003c/tr\u003e\n \u003ctr class=\"total\"\u003e\u003ctd\u003eTotal\u003c/td\u003e\u003ctd id=\"total\"\u003eWaiting for the host...\u003c/td\u003e\u003c/tr\u003e\n \u003c/table\u003e\n \u003cbutton id=\"save\" disabled\u003eSave receipt\u003c/button\u003e\n \u003cscript\u003e\n var port = null;\n var bill = { amount: 0, tipPercent: 0, total: 0 };\n var nextCallId = 1;\n var activeCallId = null;\n\n function toNumber(value) {\n var number = Number(value);\n return isNaN(number) ? 0 : number;\n }\n\n function cents(value) {\n return Math.round(value * 100) / 100;\n }\n\n function recalculate() {\n var tip = cents(bill.amount * bill.tipPercent / 100);\n bill.total = cents(bill.amount + tip);\n document.getElementById('amount').textContent = bill.amount.toFixed(2);\n document.getElementById('percent').textContent = bill.tipPercent;\n document.getElementById('tip').textContent = tip.toFixed(2);\n // \"total\" is listed in mutableData, so the host writes it back to /bill/total.\n port.postMessage({ type: 'a2ui_data_model_change', key: 'total', value: bill.total });\n // formatCurrency is listed in allowedFunctions, so the host evaluates it with the\n // surface catalog and answers with a2ui_function_result.\n activeCallId = 'total-' + nextCallId++;\n port.postMessage({\n type: 'a2ui_function_call',\n call: 'formatCurrency',\n callId: activeCallId,\n args: { value: bill.total, currency: 'USD', decimals: 2 }\n });\n port.postMessage({ type: 'a2ui_size_changed', height: document.body.scrollHeight + 32 });\n }\n\n function onPortMessage(event) {\n var msg = event.data || {};\n if (msg.type === 'a2ui_data_model_update' \u0026\u0026 (msg.key === 'amount' || msg.key === 'tipPercent')) {\n bill[msg.key] = toNumber(msg.value);\n recalculate();\n } else if (msg.type === 'a2ui_function_result' \u0026\u0026 msg.status === 'success' \u0026\u0026 msg.callId === activeCallId) {\n document.getElementById('total').textContent = msg.result;\n // \"totalLabel\" is mutable too, so the sibling Text component shows the same string.\n port.postMessage({ type: 'a2ui_data_model_change', key: 'totalLabel', value: msg.result });\n }\n }\n\n // Only the handshake travels over the ambient window channel. Everything after it uses\n // the MessagePort transferred with a2ui_app_frame_init.\n window.addEventListener('message', function (event) {\n if (event.source !== window.parent) return;\n var msg = event.data || {};\n if (msg.type !== 'a2ui_app_frame_init' || !event.ports || !event.ports[0]) return;\n port = event.ports[0];\n port.onmessage = onPortMessage;\n var initial = (msg.value \u0026\u0026 msg.value.initialData) || {};\n bill.amount = toNumber(initial.amount);\n bill.tipPercent = toNumber(initial.tipPercent);\n document.getElementById('save').disabled = false;\n recalculate();\n });\n\n document.getElementById('save').addEventListener('click', function () {\n // \"receipt_saved\" is listed in allowedEvents, so the host forwards it to the agent.\n port.postMessage({ type: 'a2ui_action', action: 'receipt_saved', data: bill });\n });\n\n window.parent.postMessage({ type: 'a2ui_app_frame_ready' }, '*');\n \u003c/script\u003e\n \u003c/body\u003e\n\u003c/html\u003e\n" |
1fc1d74 to
ff00e67
Compare
ff00e67 to
7091e22
Compare
7091e22 to
4deb6b6
Compare
4deb6b6 to
610b72b
Compare
610b72b to
633e651
Compare
633e651 to
d60512b
Compare
d60512b to
f941ffb
Compare
31b2fff to
70d7cae
Compare
5bab9e6 to
889ca3f
Compare
889ca3f to
aaa240b
Compare
aaa240b to
09cd5d3
Compare
09cd5d3 to
c0e9ac2
Compare
c0e9ac2 to
3db162a
Compare
3db162a to
586a90a
Compare
57f28eb to
cb52000
Compare
cb52000 to
3e8e371
Compare
3e8e371 to
3cf5b38
Compare
ab0b508 to
3644913
Compare
3644913 to
f9c31ab
Compare
…atalog A WebAppFrameSrcdoc app that reads the bill from bound data, writes the total back through mutableData, formats it with the basic catalog's formatCurrency through allowedFunctions and reports the saved receipt as an action. tests/examples/srcdoc_tip_calculator.test.ts runs the example through the real sandbox proxy and drives it through the host data model.
Summary
Adds
srcdoc-tip-calculator.json(45_srcdoc-tip-calculator) to the iframe catalog examples and wires@a2ui/catalog-iframeinto the Lit, Angular, and React explorer apps: aWebAppFrameSrcdocapp that reads the bill amount and tip percentage typed into two siblingTextFields, computes the total, formats it through the hostformatCurrencyfunction, and writes both back to the data model where a siblingTextshows the label. Its "Save receipt" button dispatches areceipt_savedaction.Changes
catalogs/iframe/examples/srcdoc-tip-calculator.json: the example, with the inline app written as documentation.typescript/catalogs/iframe/tests/examples/srcdoc_tip_calculator.test.ts,catalog.test.ts,README.md,CHANGELOG.md: iframe catalog example registration and Karma end-to-end test.renderers/{lit,angular,react}/a2ui_explorer: registers@a2ui/catalog-iframe(v1.0IFRAME_CATALOG_ID), adds45_srcdoc-tip-calculatorto the example gallery, and adds Karma integration tests (45_srcdoc-tip-calculator.{spec.ts,test.tsx}).Verification
yarn --cwd typescript/catalogs/iframe test: Karma specs pass.yarn --cwd renderers/{lit,react}/a2ui_explorer test:karma: pass.python3 specification/scripts/validate.pyand./scripts/fix_format.sh --checkpass.Related to #3157