Skip to content

Commit 5e57170

Browse files
authored
fix(extension): preserve capture focus and Sim Link routes (#9665)
Allow selected read-only custom-element text, preserve the initiating composer focus through deferred capture, and correct encoded organization bookmark names and legacy Sim aliases. Retain keyboard and mouse denial coverage with real typing assertions. Four regressions reproduced on the unfixed code; final CI passed with 55 successful checks and 26 intentional skips.
1 parent 8ec7514 commit 5e57170

11 files changed

Lines changed: 143 additions & 59 deletions

File tree

‎apps/extension/scripts/test-e2e.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -532,14 +532,28 @@ try {
532532
}
533533
assert(snapshot.url === `${pageOrigin}/ticket`, 'Source URL contains query or fragment')
534534
})
535-
await check('Selection attaches only the selected excerpt', async () => {
535+
await check('A focused read-only custom element attaches only the selected excerpt', async () => {
536536
await page.evaluate(() => {
537537
const selected = document.getElementById('selected')
538538
if (!selected) throw new Error('Missing selection fixture')
539+
customElements.define('readable-article', class extends HTMLElement {})
540+
const host = document.createElement('readable-article')
541+
host.tabIndex = 0
542+
selected.replaceWith(host)
543+
host.append(selected)
544+
host.focus()
539545
const range = document.createRange()
540546
range.selectNodeContents(selected)
541-
window.getSelection()?.removeAllRanges()
542-
window.getSelection()?.addRange(range)
547+
const selection = window.getSelection()
548+
selection?.removeAllRanges()
549+
selection?.addRange(range)
550+
if (
551+
document.activeElement !== host ||
552+
host.isContentEditable ||
553+
host.shadowRoot ||
554+
selection?.toString() !== selected.textContent
555+
)
556+
throw new Error('Fixture did not focus and select ordinary custom-element text')
543557
})
544558
await capture('Selection')
545559
await app.waitForFunction(
@@ -551,6 +565,7 @@ try {
551565
snapshot.text === 'The customer needs a replacement by Friday.',
552566
'Selection widened beyond the user excerpt'
553567
)
568+
await page.locator('readable-article').evaluate((host) => host.replaceWith(...host.childNodes))
554569
})
555570
await check('A focused child frame cannot substitute an old parent-page selection', async () => {
556571
await page.evaluate(() => {

‎apps/extension/src/background.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ function open(tab: chrome.tabs.Tab, trigger: 'page' | 'selection' | 'keyboard')
5555
active instanceof HTMLElement &&
5656
(active.isContentEditable ||
5757
active.matches('iframe,frame,input,textarea,select') ||
58-
active.localName.includes('-') ||
5958
Boolean(chrome.dom.openOrClosedShadowRoot(active)?.activeElement))
6059
return {
6160
href: location.href,

‎apps/extension/src/extract.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ export function extractPageText(
2121
active instanceof HTMLElement &&
2222
(active.isContentEditable ||
2323
active.matches('input,textarea,select') ||
24-
active.localName.includes('-') ||
2524
chrome.dom.openOrClosedShadowRoot(active)?.activeElement)
2625
)
2726
return { error: 'Select text outside an editable or embedded field.' }

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ export function Composer({
203203
history.reset()
204204
editor.clear()
205205
},
206-
() => editorRef.current.getValue() === raw && attachedFilesRef.current === filesAtSend
206+
() => editorRef.current.getValue() === raw && attachedFilesRef.current === filesAtSend,
207+
textareaRef.current
207208
)
208209
}
209210

‎apps/sim/app/sim-links/[organizationId]/[name]/page.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ interface OrganizationLinkPageProps {
1313

1414
/** Bookmarks stay in their exporting organization even after the browser's active team changes. */
1515
export default async function OrganizationLinkPage({ params }: OrganizationLinkPageProps) {
16-
const { organizationId, name } = await params
16+
const { organizationId, name: encodedName } = await params
17+
const name = decodeURIComponent(encodedName)
1718
if (!simLinkNameSchema.safeParse(name).success) notFound()
1819
const routes = organizationRoutes(organizationId)
1920
const session = await getSession()

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,8 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
574574
clearComposer,
575575
() =>
576576
editorRef.current.getValue() === raw &&
577-
filesRef.current.attachedFiles === currentFiles.attachedFiles
577+
filesRef.current.attachedFiles === currentFiles.attachedFiles,
578+
currentEditor.textareaRef.current
578579
)
579580
}, [chatPlanRequired, workspaceId, onSubmit, clearComposer, acceptSubmission])
580581

‎apps/sim/hooks/use-composer-submission.ts‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,37 +26,48 @@ interface UseComposerSubmissionProps {
2626

2727
export function useComposerSubmission({ scope, mode }: UseComposerSubmissionProps) {
2828
const active = useRef<PendingComposerSubmission | null>(null)
29-
const focusBeforeSend = useRef<HTMLElement | null>(null)
29+
const focusAfterSend = useRef<{
30+
input: HTMLTextAreaElement
31+
initiator: Element | null
32+
} | null>(null)
3033
const [pending, setPending] = useState(false)
3134

3235
useLayoutEffect(() => {
3336
setPending(false)
3437
return () => {
3538
active.current = null
36-
focusBeforeSend.current = null
39+
focusAfterSend.current = null
3740
}
3841
}, [scope, mode])
3942

4043
useLayoutEffect(() => {
4144
if (pending) return
42-
const previous = focusBeforeSend.current
43-
focusBeforeSend.current = null
44-
if (previous?.isConnected && document.hasFocus() && document.activeElement === document.body) {
45-
previous.focus({ preventScroll: true })
45+
const focus = focusAfterSend.current
46+
focusAfterSend.current = null
47+
if (
48+
focus?.input.isConnected &&
49+
document.hasFocus() &&
50+
(document.activeElement === document.body || document.activeElement === focus.initiator)
51+
) {
52+
focus.input.focus({ preventScroll: true })
4653
}
4754
}, [pending])
4855

4956
const accept = useCallback(
50-
(submit: () => unknown, clear: () => void, unchanged: () => boolean) => {
57+
(
58+
submit: () => unknown,
59+
clear: () => void,
60+
unchanged: () => boolean,
61+
input: HTMLTextAreaElement | null
62+
) => {
5163
if (active.current) return
5264
const result = submit()
5365
if (!(result instanceof PendingComposerSubmission)) {
5466
if (result !== false) clear()
5567
return
5668
}
5769
active.current = result
58-
focusBeforeSend.current =
59-
document.activeElement instanceof HTMLElement ? document.activeElement : null
70+
focusAfterSend.current = input ? { input, initiator: document.activeElement } : null
6071
setPending(true)
6172
void result.ready
6273
.then((commit) => {

‎apps/sim/proxy.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,14 +233,20 @@ const SUSPICIOUS_UA_PATTERNS = [
233233
] as const
234234

235235
/**
236-
* Handles authentication-based redirects for root paths
236+
* Resolves legacy homepage aliases and authentication-based root redirects.
237237
*/
238238
function handleRootPathRedirects(
239239
request: NextRequest,
240240
hasActiveSession: boolean
241241
): NextResponse | null {
242242
const url = request.nextUrl
243243

244+
if (url.pathname !== '/sim' && url.pathname.toLowerCase() === '/sim') {
245+
const destination = url.clone()
246+
destination.pathname = '/'
247+
return NextResponse.redirect(destination, 308)
248+
}
249+
244250
if (url.pathname !== '/' || url.searchParams.has('home')) {
245251
return null
246252
}

‎apps/sim/scripts/test-browser-extension-e2e.ts‎

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2023,35 +2023,52 @@ try {
20232023
assert.equal(manual.length, 1)
20242024
assert(manual[0].text.includes('ORCHID-482') && !manual[0].text.includes('JADE-231'))
20252025

2026-
await openScenario(target, `${org ? 'organization' : 'workspace'}-denied`)
2027-
await expect(input()).toBeVisible()
2028-
await restricted.bringToFront()
2029-
const draft = 'Keep this draft if access is unavailable'
2030-
await input().fill(draft)
2031-
count = received.length
2032-
phase = 'denied-send'
2033-
await input().press('Enter')
2034-
await expect(
2035-
shell.getByRole('button', { name: 'Request site access', exact: true })
2036-
).toBeEnabled()
2037-
await expect(input()).toHaveValue(draft)
2038-
assert.equal(received.length, count)
2039-
await screenshot(`reference-${org ? 'organization' : 'workspace'}-denied`)
2040-
const accessNotice = await shell
2041-
.getByRole('status')
2042-
.first()
2043-
.evaluate((status) => status.firstChild?.textContent?.trim())
2044-
assert(accessNotice)
2045-
assert.equal(
2046-
await frame().getByText(accessNotice, { exact: true }).count(),
2047-
0,
2048-
'Access guidance covered the composer twice'
2049-
)
2050-
await setReference(false)
2051-
phase = 'opt-out-send'
2052-
await input().press('Enter')
2053-
await expect.poll(() => received.length).toBe(count + 1)
2054-
assert.equal(submittedPages(received[count]).length, 0)
2026+
for (const gesture of ['keyboard', 'mouse'] as const) {
2027+
await openScenario(target, `${org ? 'organization' : 'workspace'}-denied-${gesture}`)
2028+
await expect(input()).toBeVisible()
2029+
await restricted.bringToFront()
2030+
const draft = 'Keep this draft if access is unavailable'
2031+
await input().fill(draft)
2032+
count = received.length
2033+
phase = `denied-${gesture}-send`
2034+
if (gesture === 'keyboard') await input().press('Enter')
2035+
else
2036+
await frame()
2037+
.getByRole('button', { name: org ? 'Send' : 'Send message', exact: true })
2038+
.click()
2039+
await expect(
2040+
shell.getByRole('button', { name: 'Request site access', exact: true })
2041+
).toBeEnabled()
2042+
await expect(input()).toHaveValue(draft)
2043+
await expect
2044+
.poll(() => input().evaluate((element) => Boolean(element.closest('[inert]'))))
2045+
.toBe(false)
2046+
assert.equal(received.length, count)
2047+
assert(
2048+
await frame().evaluate(() => document.hasFocus()),
2049+
`The denied ${gesture} send must keep the chat document focused`
2050+
)
2051+
await expect(input()).toBeFocused()
2052+
await shell.keyboard.type(' Continue typing after the denied capture.')
2053+
await expect(input()).toHaveValue(`${draft} Continue typing after the denied capture.`)
2054+
assert.equal(received.length, count)
2055+
await screenshot(`reference-${org ? 'organization' : 'workspace'}-denied-${gesture}`)
2056+
const accessNotice = await shell
2057+
.getByRole('status')
2058+
.first()
2059+
.evaluate((status) => status.firstChild?.textContent?.trim())
2060+
assert(accessNotice)
2061+
assert.equal(
2062+
await frame().getByText(accessNotice, { exact: true }).count(),
2063+
0,
2064+
'Access guidance covered the composer twice'
2065+
)
2066+
await setReference(false)
2067+
phase = 'opt-out-send'
2068+
await input().press('Enter')
2069+
await expect.poll(() => received.length).toBe(count + 1)
2070+
assert.equal(submittedPages(received[count]).length, 0)
2071+
}
20552072
}
20562073
} finally {
20572074
await browserContext.unroute(endpoint)

‎apps/sim/scripts/test-landing-seo-e2e.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,34 @@ async function run() {
327327
assert.deepEqual(failures, [])
328328
})
329329

330+
await check(
331+
'legacy Sim aliases preserve queries without redirecting the Sim Links entry',
332+
async () => {
333+
const entry = await get('/sim')
334+
assert.equal(entry.status, 307)
335+
assert(entry.location, 'The Sim Links entry must offer sign-in')
336+
const login = new URL(entry.location, baseUrl)
337+
assert.equal(login.pathname, '/login')
338+
assert.equal(login.searchParams.get('callbackUrl'), '/sim')
339+
for (const path of ['/Sim', '/SIM', '/sIm']) {
340+
const { status, location } = await get(`${path}?home&utm_source=legacy`)
341+
assert.equal(status, 308, path)
342+
assert(location, `${path}: expected the legacy homepage redirect`)
343+
const target = new URL(location, baseUrl)
344+
assert.equal(target.origin, baseUrl.origin, path)
345+
assert.equal(target.pathname, '/', path)
346+
assert.deepEqual(
347+
[...target.searchParams],
348+
[
349+
['home', ''],
350+
['utm_source', 'legacy'],
351+
],
352+
path
353+
)
354+
}
355+
}
356+
)
357+
330358
await check('integration sitemap dates are per integration, not one catalog date', async () => {
331359
const mismatches: string[] = []
332360
for (const integration of integrations) {

0 commit comments

Comments
 (0)