diff --git a/examples/framey-settings/README.md b/examples/framey-settings/README.md index 48b97fe..acfc089 100644 --- a/examples/framey-settings/README.md +++ b/examples/framey-settings/README.md @@ -14,6 +14,9 @@ action calls the backend, so it reads the persisted setting rather than a browser cache. A missing, damaged, or old non-boolean value reads as the caller's default; the next save atomically replaces the sample's settings file. +The panel also guards its own read and write calls synchronously. If two +controller events arrive before React redraws a disabled control, the second +event is ignored; after the request settles, a new change can be saved. The **About this setting** button opens a local detail page at `/framey-sample/details/saved-setting`. It demonstrates `routerHook.addRoute`, diff --git a/examples/framey-settings/dist/index.js b/examples/framey-settings/dist/index.js index 3c72c18..4a38397 100644 --- a/examples/framey-settings/dist/index.js +++ b/examples/framey-settings/dist/index.js @@ -30,7 +30,11 @@ function Content() { const [enabled, setEnabled] = SP_REACT.useState(false); const [busy, setBusy] = SP_REACT.useState(true); const [message, setMessage] = SP_REACT.useState('Loading setting…'); + const pending = SP_REACT.useRef(false); async function refresh() { + if (pending.current) + return; + pending.current = true; setBusy(true); try { const value = await getSetting('enabled', false); @@ -41,11 +45,17 @@ function Content() { setMessage(`Could not read setting: ${String(error)}`); } finally { + pending.current = false; setBusy(false); } } SP_REACT.useEffect(() => { void refresh(); }, []); async function change(value) { + // Two controller events can arrive before React redraws the disabled toggle. + // Keep writes ordered so a late first reply cannot replace the final choice. + if (pending.current) + return; + pending.current = true; setBusy(true); try { await setSetting('enabled', value); @@ -58,6 +68,7 @@ function Content() { setMessage(`Could not save setting: ${String(error)}`); } finally { + pending.current = false; setBusy(false); } } diff --git a/examples/framey-settings/dist/index.js.map b/examples/framey-settings/dist/index.js.map index 6c0da6b..8c065d0 100644 --- a/examples/framey-settings/dist/index.js.map +++ b/examples/framey-settings/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample',\n body: Setting saved: {value ? 'on' : 'off'}});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return \n \n {void change(value);}} />\n \n {void refresh();}}>Reload saved setting\n Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting\n

{message}

\n
;\n}\n\nfunction Details() {\n const {topic} = useParams<{topic?: string}>();\n return \n

Topic: {topic || 'overview'}

\n

The toggle is stored by this plugin's Decky backend in the user's settings directory.

\n Navigation.NavigateBack()}>Back to setting\n
;\n}\n\nexport default definePlugin(() => {\n routerHook.addRoute('/framey-sample/details/:topic?', Details);\n return Object.assign({\n name: 'Framey Settings Sample',\n icon: ⚙,\n content: ,\n onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),\n}, {\n // Framey additions are ignored by Decky's own menu. Keep the main content\n // usable there too, while showing authors the extra VR settings view.\n settings: ,\n frameyQuickActions: [{\n label: 'Read saved setting',\n onClick: async () => {\n const enabled = await getSetting('enabled', false);\n return `Saved setting is ${enabled ? 'on' : 'off'}`;\n },\n }],\n });\n});\n"],"names":[],"mappings":"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","x_google_ignoreList":[0]} \ No newline at end of file +{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';\nimport {useEffect, useRef, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n const pending = useRef(false);\n\n async function refresh() {\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n // Two controller events can arrive before React redraws the disabled toggle.\n // Keep writes ordered so a late first reply cannot replace the final choice.\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample',\n body: Setting saved: {value ? 'on' : 'off'}});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n return \n \n {void change(value);}} />\n \n {void refresh();}}>Reload saved setting\n Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting\n

{message}

\n
;\n}\n\nfunction Details() {\n const {topic} = useParams<{topic?: string}>();\n return \n

Topic: {topic || 'overview'}

\n

The toggle is stored by this plugin's Decky backend in the user's settings directory.

\n Navigation.NavigateBack()}>Back to setting\n
;\n}\n\nexport default definePlugin(() => {\n routerHook.addRoute('/framey-sample/details/:topic?', Details);\n return Object.assign({\n name: 'Framey Settings Sample',\n icon: ⚙,\n content: ,\n onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),\n}, {\n // Framey additions are ignored by Decky's own menu. Keep the main content\n // usable there too, while showing authors the extra VR settings view.\n settings: ,\n frameyQuickActions: [{\n label: 'Read saved setting',\n onClick: async () => {\n const enabled = await getSetting('enabled', false);\n return `Saved setting is ${enabled ? 'on' : 'off'}`;\n },\n }],\n });\n});\n"],"names":[],"mappings":"AAEA,MAAA,QAAA,GAAiB,CAAA,MAAA,CAAA,wBAAA,CAAS;AAC1B,MAAA,WAAA,GAAA,CAAA;AAIA,MAAA,qBAAA,GAAA,MAAA,CAAA,2EAAA;AACA,IAAA,CAAA,qBAAA,EAAA;AACA,IAAA,MAAA,IAAA,KAAA,CAAA,+HAAA,CAAA;AACA;AACA,IAAA,GAAA;AACA,IAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,WAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA;AACA,MAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,CAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,4EAAA,CAAA,CAAA;AACA;AACA,IAAA,GAAA,CAAA,QAAA,IAAA,WAAA,EAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,8CAAA,EAAA,GAAA,CAAA,QAAA,CAAA,6BAAA,CAAA,CAAA;AACA;AAEA,MAAA,QAAA,GAAA,GAAA,CAAA,QAAA;AAGA,MAAA,UAAA,GAAA,GAAA,CAAA,UAAA;AACA,MAAA,OAAA,GAAA,GAAA,CAAA,OAAA;AAQA,MAAA,YAAA,GAAA,CAAA,EAAA,KAAA;AACA,IAAA,OAAA,CAAA,GAAA,IAAA,KAAA;AACA,QAAA,OAAA,EAAA,CAAA,GAAA,IAAA,CAAA;AACA,IAAA,CAAA;AACA,CAAA;;ACnCA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AACA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AAEA,SAAA,OAAA,GAAA;kCACgC,QAAA,CAAA,QAAQ,CAAA,KAAA,CAAA;4BACd,QAAA,CAAA,QAAQ,CAAA,IAAA,CAAA;kCACF,QAAA,CAAA,QAAQ,CAAA,kBAAA,CAAA;AACtC,IAAA,MAAA,OAAA,GAAgB,QAAA,CAAA,MAAM,CAAA,KAAA,CAAA;AAEtB,IAAA,eAAA,OAAA,GAAA;;;AAEE,QAAA,OAAA,CAAA,OAAA,GAAA,IAAA;;AAEA,QAAA,IAAA;;;AAGE,YAAA,UAAA,CAAA,CAAA,eAAA,EAAA,KAAA,GAAA,IAAA,GAAA,KAAA,CAAA,CAAA,CAAA;;;;;;AAIA,YAAA,OAAA,CAAA,OAAA,GAAA,KAAA;;;;AAKJ,IAAA,QAAA,CAAA,SAAS,CAAA,MAAA,EAAA,KAAA,OAAA,EAAA,CAAA,CAAA,CAAA,EAAA,EAAA,CAAA;;;;;;AAMP,QAAA,OAAA,CAAA,OAAA,GAAA,IAAA;;AAEA,QAAA,IAAA;AACE,YAAA,MAAA,UAAA,CAAA,SAAA,EAAA,KAAA,CAAA;;AAEA,YAAA,UAAA,CAAA,CAAA,eAAA,EAAA,KAAA,GAAA,IAAA,GAAA,KAAA,CAAA,CAAA,CAAA;AACA,YAAA,OAAA,CAAA,KAAA,CAAA,EAAA,KAAA,EAAA,eAAA;AACE,gBAAA,IAAA,EAAM,MAAA,CAAA,IAAA,yCAAqB,MAAA,CAAA,GAAA,CAAA,QAAA,EAAA,EAAA,QAAA,EAAA,KAAA,GAAA,IAAA,GAAA,KAAA,EAAA,CAAA,CAAA,EAAA,CAAA,EAAA,CAAA;;;;;;AAI7B,YAAA,OAAA,CAAA,OAAA,GAAA,KAAA;;;;WAKG,MAAA,CAAA,IAAA,CAAC,GAAA,CAAA,YAAY,EAAA,EAAA,KAAA,EAAA,wBAAA,EAAA,QAAA,EAAA,CAClB,UAAA,CAAC,GAAA,CAAA,eAAe,EAAA,EAAA,QAAA,EACd,MAAA,CAAA,GAAA,CAAC,GAAA,CAAA,WAAW,EAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,WAAA,EAAA,oCAAA,EAAA,OAAA,EAAA,OAAA,EAAA,QAAA,EAAA,IAAA,EAAA,QAAA,EAAA,CAAA,KAAA,KAAA,EAAA,KAAA,MAAA,CAAA,KAAA,CAAA,CAAA,CAAA,CAAA,EAAA,CAAA,EAAA,CAAA,EAGd,MAAA,CAAA,GAAA,CAAC,mBAAe,EAAA,EAAA,QAAA,EAAC,MAAA,CAAA,GAAA,CAAC,GAAA,CAAA,UAAU,EAAA,EAAA,QAAA,EAAA,IAAA,EAAA,OAAA,EAAA,MAAA,EAAA,KAAA,OAAA,EAAA,CAAA,CAAA,CAAA,EAAA,QAAA,EAAA,sBAAA,EAAA,CAAA,EAAA,CAAA,EAC5B,MAAA,CAAA,IAAC,GAAA,CAAA,eAAe,cAAC,MAAA,CAAA,GAAA,CAAC,GAAA,CAAA,UAAU,EAAA,EAAA,OAAA,EAAA,MAAgB,cAAU,CAAA,QAAA,CAAA,sCAAA,CAAA,EAAA,QAAA,EAAA,oBAAA,EAAA,CAAA,EAAA,CAAA,EACtD,MAAA,CAAA,IAAC,GAAA,CAAA,eAAe,cAAC,MAAA,CAAA,GAAA,CAAA,GAAA,EAAA,EAAA,IAAA,EAAA,QAAA,EAAA,QAAA,EAAA,OAAA,EAAA,CAAA,EAAA,CAAA,CAAA,EAAA,CAAA;AAErB;AAEA,SAAA,OAAA,GAAA;AACE,IAAA,MAAA,EAAA,KAAA,EAAA,GAAgB,GAAA,CAAA,SAAS,EAAA;AACzB,IAAA,OAAO,MAAA,CAAA,KAAC,GAAA,CAAA,YAAY,EAAA,EAAA,KAAA,EAAA,oBAAA,EAAA,QAAA,EAAA,CAClB,MAAA,CAAA,GAAA,CAAC,GAAA,CAAA,eAAe,EAAA,EAAA,QAAA,EAAC,MAAA,CAAA,IAAA,CAAA,GAAA,EAAA,EAAA,QAAA,EAAA,CAAA,SAAA,EAAA,KAAA,IAAA,UAAA,CAAA,EAAA,CAAA,EAAA,CAAA,EACjB,MAAA,CAAA,IAAC,mBAAe,EAAA,EAAA,QAAA,EAAC,MAAA,CAAA,GAAA,CAAA,GAAA,EAAA,EAAA,QAAA,EAAA,uFAAA,EAAA,CAAA,EAAA,CAAA,EACjB,MAAA,CAAA,GAAA,CAAC,GAAA,CAAA,eAAe,cAAC,MAAA,CAAA,GAAA,CAAC,GAAA,CAAA,UAAU,EAAA,EAAA,OAAA,EAAA,MAAgB,GAAA,CAAA,UAAU,CAAA,YAAA,EAAA,EAAA,QAAA,EAAA,iBAAA,EAAA,CAAA,EAAA,CAAA,CAAA,EAAA,CAAA;AAE1D;AAEA,YAAA,YAAA,CAAA,MAAA;AACE,IAAA,UAAA,CAAA,QAAA,CAAA,gCAAA,EAAA,OAAA,CAAA;;AAEA,QAAA,IAAA,EAAA,wBAAA;cACM,MAAA,CAAA,GAAA,CAAA,MAAA,EAAA,EAAA,aAAA,EAAA,MAAA,EAAA,QAAA,EAAA,QAAA,EAAA,CAAA;iBACG,MAAA,CAAA,GAAA,CAAA,OAAA,EAAA,EAAA,CAAA;;;;;kBAKC,MAAA,CAAA,GAAA,CAAA,OAAA,EAAA,EAAA,CAAA;AACV,QAAA,kBAAA,EAAA,CAAA;AACE,gBAAA,KAAA,EAAA,oBAAA;;;;;;AAMD,KAAA,CAAA;AACH,CAAA,CAAA;;","x_google_ignoreList":[0]} \ No newline at end of file diff --git a/examples/framey-settings/src/index.tsx b/examples/framey-settings/src/index.tsx index a2c5d1f..25f1db1 100644 --- a/examples/framey-settings/src/index.tsx +++ b/examples/framey-settings/src/index.tsx @@ -1,6 +1,6 @@ import {callable, definePlugin, routerHook, toaster} from '@decky/api'; import {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui'; -import {useEffect, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting'); const setSetting = callable<[key: string, value: boolean], boolean>('set_setting'); @@ -9,8 +9,11 @@ function Content() { const [enabled, setEnabled] = useState(false); const [busy, setBusy] = useState(true); const [message, setMessage] = useState('Loading setting…'); + const pending = useRef(false); async function refresh() { + if (pending.current) return; + pending.current = true; setBusy(true); try { const value = await getSetting('enabled', false); @@ -19,6 +22,7 @@ function Content() { } catch (error) { setMessage(`Could not read setting: ${String(error)}`); } finally { + pending.current = false; setBusy(false); } } @@ -26,6 +30,10 @@ function Content() { useEffect(() => {void refresh();}, []); async function change(value: boolean) { + // Two controller events can arrive before React redraws the disabled toggle. + // Keep writes ordered so a late first reply cannot replace the final choice. + if (pending.current) return; + pending.current = true; setBusy(true); try { await setSetting('enabled', value); @@ -36,6 +44,7 @@ function Content() { } catch (error) { setMessage(`Could not save setting: ${String(error)}`); } finally { + pending.current = false; setBusy(false); } } diff --git a/tests/page/framey_sample.mjs b/tests/page/framey_sample.mjs index 82b710d..69fa5fd 100644 --- a/tests/page/framey_sample.mjs +++ b/tests/page/framey_sample.mjs @@ -8,7 +8,8 @@ const bundle=readFileSync(new URL('../../examples/framey-settings/dist/index.js' assert.match(bundle,/SP_JSX\.jsx/); const React={Fragment:Symbol('fragment'),Component:class {}, createElement:(type,props,...children)=>({type,props:props||{},children}), - useState:value=>[value,()=>{}],useEffect(){},isValidElement:value=>!!value?.type}; + useState:value=>[value,()=>{}],useRef:value=>({current:value}), + useEffect(){},isValidElement:value=>!!value?.type}; class Element { constructor(){this.children=[];this.textContent='';this.attributes={};} append(...nodes){this.children.push(...nodes);} @@ -51,8 +52,12 @@ assert.equal(calls[0].method,'get_setting'); assert.deepEqual(Array.from(calls[0].args),['enabled',false]); // Exercise the built sample's save path through the real Framey toast host. // Its JSX body must reach a React root, rather than stringify to an object. -content.children[0][0].children[0].props.onChange(true); +const toggle=content.children[0][0].children[0]; +toggle.props.onChange(true); +toggle.props.onChange(false); // Before React redraws disabled, do not race the first write. await new Promise(resolve=>setImmediate(resolve)); +assert.equal(calls.filter(call=>call.method==='set_setting').length,1); +assert.deepEqual(Array.from(calls.find(call=>call.method==='set_setting').args),['enabled',true]); const toast=elements.get('framey-toasts').children[0]; assert.equal(toast.attributes.role,'status'); assert.equal(toast.children[0].textContent,'Framey sample'); @@ -62,6 +67,10 @@ assert.match(JSON.stringify(toastBody),/Setting saved/); assert.match(JSON.stringify(toastBody),/on/); toast.children[2].onclick(); assert.equal(roots.get(toast.children[1]).unmounted,true); +toggle.props.onChange(false); +await new Promise(resolve=>setImmediate(resolve)); +assert.equal(calls.filter(call=>call.method==='set_setting').length,2, + 'a completed save must release the sample setting lock'); assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),true); assert.equal(window.DFL.useParams().topic,'saved-setting'); assert.equal(window.DFL.Navigation.Navigate('/library/home'),false);