Skip to content

Commit 2e5e66e

Browse files
beam_formerclaude
andcommitted
Add @srfnstack/fntags-testing library, fix memory leaks, migrate unit tests
- Add fntags-testing package: thin wrapper over @testing-library/dom with render(), cleanup(), and re-exports of screen/fireEvent/waitFor/within - Fix selectObserver leak: subscribeSelect now returns unsub, registered in activeRenderCleanups for automatic cleanup on parent bindAs re-render - Fix conditional selectObserver cleanup in arrangeElements that skipped cleanup when replacement element lacked insertAdjacentElement (text nodes) - Fix observer iteration safety: snapshot arrays before iterating to prevent skipped callbacks when subscribers modify the list during notification - Fix bindChildren per-item subscription cleanup when nested inside bindAs - Migrate all Cypress unit tests to vitest (keep docs e2e tests in Cypress) - Add testing docs page to the docs site - Make search bar collapsible (SVG icon that expands to input on click) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6c0c84b commit 2e5e66e

23 files changed

Lines changed: 4290 additions & 424 deletions

docs/header.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export default header({ class: 'container text-center' },
4646
style: `border-bottom: solid 1px darkgray; background-color: ${primaryColor}; position: relative; padding: 0 10px;`
4747
},
4848
div({ class: 'flex-center noselect', style: 'flex-grow: 1; flex-wrap: wrap;' }, ...routeNavItems()),
49-
div({ style: 'position: absolute; right: 10px;' }, searchBar())
49+
searchBar()
5050
), // Add mobile layout adjustment
5151
style(`
5252
@media (max-width: 600px) {

docs/lib/fntags.mjs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,27 @@ function doBindChildren (parent, element) {
417417
throw new Error('You can only use bindChildren with a state that contains an array. try myState([mystate]) before calling this function.')
418418
}
419419

420-
ctx.bindContexts.push({ element, parent })
420+
const bindContext = { element, parent }
421+
ctx.bindContexts.push(bindContext)
422+
423+
// If inside a parent bindAs render, register a cleanup that tears down all
424+
// per-item subscriptions when the parent re-renders or is itself torn down.
425+
// Without this, nested bindAttr/bindStyle/bindAs subscriptions created for
426+
// each array item would be orphaned when the parent replaces this tree.
427+
if (activeRenderCleanups !== null) {
428+
activeRenderCleanups.push(() => {
429+
if (bindContext.elementCleanups) {
430+
for (const key in bindContext.elementCleanups) {
431+
for (const unsub of bindContext.elementCleanups[key]) unsub()
432+
}
433+
bindContext.elementCleanups = {}
434+
}
435+
// Remove this bindContext so reconcile doesn't operate on stale state
436+
const idx = ctx.bindContexts.indexOf(bindContext)
437+
if (idx !== -1) ctx.bindContexts.splice(idx, 1)
438+
})
439+
}
440+
421441
this.subscribe((_, oldState) => {
422442
if (!Array.isArray(ctx.currentValue)) {
423443
console.warn('A state used with bindChildren was updated to a non array value. This will be converted to an array of 1 and the state will be updated.')

docs/routes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import state from './state.js'
44
import routing from './routing.js'
55
import gettingStarted from './gettingStarted.js'
66
import api from './api.js'
7+
import testing from './testing.js'
78

89
import { fnlink, pathState, route } from './lib/fnroute.mjs'
910
import { secondaryColor } from './constants.js'
@@ -14,6 +15,7 @@ export const routes = [
1415
{ url: '/components', linkText: 'Components', component: components },
1516
{ url: '/state', linkText: 'State', component: state },
1617
{ url: '/routing', linkText: 'Routing', component: routing },
18+
{ url: '/testing', linkText: 'Testing', component: testing },
1719
{ url: '/api', linkText: 'API', component: api },
1820

1921
// {url: "/reference", linkText: 'Reference', component: reference},

docs/search.js

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { div, input, ul, li } from './lib/fnelements.mjs'
22
import { fnstate } from './lib/fntags.mjs'
3+
import { svg, path } from './lib/svgelements.mjs'
34
import { routes } from './routes.js'
45
import { goTo } from './lib/fnroute.mjs'
56
import { secondaryColor } from './constants.js'
@@ -69,9 +70,10 @@ export const searchBar = () => {
6970
const query = fnstate('')
7071
const results = fnstate([], res => res.url)
7172
const focused = fnstate(false)
73+
const expanded = fnstate(false)
7274
const showResults = fnstate(false)
7375

74-
const updateShow = () => showResults(results().length > 0 && focused())
76+
const updateShow = () => showResults(results().length > 0 && (focused() || expanded()))
7577
results.subscribe(updateShow)
7678
focused.subscribe(updateShow)
7779

@@ -90,24 +92,66 @@ export const searchBar = () => {
9092
query('')
9193
results([])
9294
focused(false)
95+
expanded(false)
9396
}
9497

98+
const collapse = () => {
99+
setTimeout(() => {
100+
if (!focused()) {
101+
expanded(false)
102+
query('')
103+
results([])
104+
}
105+
}, 200)
106+
}
107+
108+
const searchInput = input({
109+
type: 'text',
110+
placeholder: 'Search docs...',
111+
value: query.bindAttr(),
112+
oninput: (e) => query(e.target.value),
113+
onfocus: () => focused(true),
114+
onblur: () => {
115+
focused(false)
116+
collapse()
117+
},
118+
style: {
119+
padding: '6px 8px',
120+
borderRadius: '4px',
121+
border: '1px solid #ccc',
122+
width: '180px',
123+
fontSize: '14px',
124+
display: expanded.bindStyle(() => expanded() ? 'block' : 'none')
125+
}
126+
})
127+
95128
return div(
96-
{ style: 'position: relative; margin-left: auto; margin-right: 20px;' },
97-
input({
98-
type: 'text',
99-
placeholder: 'Search docs...',
100-
value: query.bindAttr(),
101-
oninput: (e) => query(e.target.value),
102-
onfocus: () => focused(true),
103-
onblur: () => setTimeout(() => focused(false), 200), // Delay to allow click
129+
{ style: 'position: relative; display: flex; align-items: center;' },
130+
div({
104131
style: {
105-
padding: '8px',
106-
borderRadius: '4px',
107-
border: '1px solid #ccc',
108-
width: '200px'
109-
}
110-
}),
132+
cursor: 'pointer',
133+
fontSize: '18px',
134+
padding: '4px 8px',
135+
display: expanded.bindStyle(() => expanded() ? 'none' : 'block')
136+
},
137+
onclick: () => {
138+
expanded(true)
139+
setTimeout(() => searchInput.focus(), 0)
140+
},
141+
title: 'Search docs'
142+
}, svg({
143+
fill: 'none',
144+
viewBox: '0 0 24 24',
145+
'stroke-width': '1.5',
146+
stroke: 'currentColor',
147+
width: '20',
148+
height: '20'
149+
}, path({
150+
'stroke-linecap': 'round',
151+
'stroke-linejoin': 'round',
152+
d: 'm21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z'
153+
}))),
154+
searchInput,
111155
div(
112156
{
113157
style: {

0 commit comments

Comments
 (0)