Skip to content

Commit 71e6cef

Browse files
committed
Merge branch 'main' of https://github.com/NotePlan/plugins
2 parents 8511613 + f6a12dd commit 71e6cef

File tree

7 files changed

+129
-108
lines changed

7 files changed

+129
-108
lines changed

dwertheimer.Forms/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
See Plugin [README](https://github.com/NotePlan/plugins/blob/main/dwertheimer.Forms/README.md) for details on available commands and use case.
66

7+
## [1.0.28] 2026-02-08 @dwertheimer
8+
9+
### Fixed
10+
- **Infinite loop on load with preloaded content**: Forms with `preloadChooserData: true` could cause an infinite render loop. FormView was passing new object/array references every render for `defaultValues` and preloaded* props (`preloadedTeamspaces`, `preloadedMentions`, `preloadedHashtags`, `preloadedEvents`, `preloadedFrontmatterValues`), which retriggered DynamicDialog's "add missing keys" useEffect repeatedly. These props are now memoized with content-based dependencies so references only change when the actual data changes.
11+
12+
### Edited in this release
13+
- `dwertheimer.Forms/src/components/FormView.jsx` — Added useMemo for defaultValuesStable and preloaded*Stable props passed to DynamicDialog.
14+
715
## [1.0.27] 2026-02-08 @dwertheimer
816

917
### Added

dwertheimer.Forms/plugin.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
"noteplan.minAppVersion": "3.4.0",
55
"plugin.id": "dwertheimer.Forms",
66
"plugin.name": "📝 Template Forms",
7-
"plugin.version": "1.0.27",
8-
"plugin.releaseStatus": "beta",
9-
"plugin.lastUpdateInfo": "FolderChooser now shows all folders. Frontmatter Key Chooser supports valueSeparator option -- space, comma, or commaSpace. Thx @jgclark!",
7+
"plugin.version": "1.1.0",
8+
"plugin.releaseStatus": "full",
9+
"plugin.lastUpdateInfo": "First non-beta release.",
1010
"plugin.description": "Dynamic Forms for NotePlan using Templating -- fill out a multi-field form and have the data sent to a template for processing",
1111
"plugin.author": "dwertheimer",
1212
"plugin.requiredFiles": ["react.c.FormView.bundle.dev.js", "react.c.FormBuilderView.bundle.dev.js", "react.c.FormBrowserView.bundle.dev.js"],

dwertheimer.Forms/src/components/FormView.jsx

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,33 @@ export function FormView({ data, dispatch, reactSettings, setReactSettings, onSu
115115
const needsFolders = useMemo(() => formFields.some((field) => field.type === 'folder-chooser'), [formFields])
116116
const needsNotes = useMemo(() => formFields.some((field) => field.type === 'note-chooser'), [formFields])
117117

118+
// Stabilize defaultValues and preloaded* props so DynamicDialog's useEffect (add missing keys)
119+
// does not re-run on every render. Passing new {} or [] every time causes infinite loops with preloaded content.
120+
const defaultValuesStable = useMemo(
121+
() => pluginData?.defaultValues ?? {},
122+
[pluginData?.defaultValues == null ? '' : JSON.stringify(pluginData.defaultValues)],
123+
)
124+
const preloadedTeamspacesStable = useMemo(
125+
() => pluginData?.preloadedTeamspaces ?? [],
126+
[pluginData?.preloadedTeamspaces == null ? '' : JSON.stringify(pluginData.preloadedTeamspaces)],
127+
)
128+
const preloadedMentionsStable = useMemo(
129+
() => pluginData?.preloadedMentions ?? [],
130+
[pluginData?.preloadedMentions == null ? '' : JSON.stringify(pluginData.preloadedMentions)],
131+
)
132+
const preloadedHashtagsStable = useMemo(
133+
() => pluginData?.preloadedHashtags ?? [],
134+
[pluginData?.preloadedHashtags == null ? '' : JSON.stringify(pluginData.preloadedHashtags)],
135+
)
136+
const preloadedEventsStable = useMemo(
137+
() => pluginData?.preloadedEvents ?? [],
138+
[pluginData?.preloadedEvents == null ? '' : JSON.stringify(pluginData.preloadedEvents)],
139+
)
140+
const preloadedFrontmatterValuesStable = useMemo(
141+
() => pluginData?.preloadedFrontmatterValues ?? {},
142+
[pluginData?.preloadedFrontmatterValues == null ? '' : JSON.stringify(pluginData.preloadedFrontmatterValues)],
143+
)
144+
118145
/**
119146
* Request data from the plugin using request/response pattern
120147
* Returns a Promise that resolves with the response data or rejects with an error
@@ -1006,14 +1033,14 @@ export function FormView({ data, dispatch, reactSettings, setReactSettings, onSu
10061033
notes={notes}
10071034
requestFromPlugin={requestFromPlugin}
10081035
windowId={pluginData.windowId} // Pass windowId to DynamicDialog
1009-
defaultValues={pluginData?.defaultValues || {}} // Pass default values for form pre-population
1036+
defaultValues={defaultValuesStable} // Pass default values for form pre-population (stable ref to avoid infinite loop)
10101037
templateFilename={pluginData?.templateFilename || ''} // Pass template filename for autosave
10111038
templateTitle={pluginData?.templateTitle || ''} // Pass template title for autosave
1012-
preloadedTeamspaces={pluginData?.preloadedTeamspaces || []} // Preloaded teamspaces for static HTML testing
1013-
preloadedMentions={pluginData?.preloadedMentions || []} // Preloaded mentions for static HTML testing
1014-
preloadedHashtags={pluginData?.preloadedHashtags || []} // Preloaded hashtags for static HTML testing
1015-
preloadedEvents={pluginData?.preloadedEvents || []} // Preloaded events for static HTML testing
1016-
preloadedFrontmatterValues={pluginData?.preloadedFrontmatterValues || {}} // Preloaded frontmatter key values for static HTML testing
1039+
preloadedTeamspaces={preloadedTeamspacesStable} // Preloaded teamspaces (stable ref to avoid infinite loop)
1040+
preloadedMentions={preloadedMentionsStable} // Preloaded mentions (stable ref to avoid infinite loop)
1041+
preloadedHashtags={preloadedHashtagsStable} // Preloaded hashtags (stable ref to avoid infinite loop)
1042+
preloadedEvents={preloadedEventsStable} // Preloaded events (stable ref to avoid infinite loop)
1043+
preloadedFrontmatterValues={preloadedFrontmatterValuesStable} // Preloaded frontmatter key values (stable ref to avoid infinite loop)
10171044
onFoldersChanged={() => {
10181045
reloadFolders()
10191046
}}

dwertheimer.Forms/src/dataHandlers.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getFoldersMatching } from '@helpers/folders'
1212
import { getAllTeamspaceIDsAndTitles } from '@helpers/NPTeamspace'
1313
import { parseTeamspaceFilename } from '@helpers/teamspace'
1414
import { keepTodayPortionOnly } from '@helpers/calendar.js'
15+
import { getValuesForFrontmatterTag } from '@helpers/NPFrontMatter'
1516
import { type RequestResponse } from './shared/types'
1617

1718
/**
@@ -599,3 +600,70 @@ export function getTeamspaces(_params: Object = {}): RequestResponse {
599600
}
600601
}
601602
}
603+
604+
/**
605+
* Get all values for a frontmatter key from DataStore
606+
* Moved here from requestHandlers.js to break circular dependency: requestHandlers -> FormFieldRenderTest -> windowManagement -> requestHandlers
607+
* @param {Object} params - Request parameters
608+
* @param {string} params.frontmatterKey - The frontmatter key to get values for
609+
* @param {'Notes' | 'Calendar' | 'All'} params.noteType - Type of notes to search (default: 'All')
610+
* @param {boolean} params.caseSensitive - Whether to perform case-sensitive search (default: false)
611+
* @param {string} params.folderString - Folder to limit search to (optional)
612+
* @param {boolean} params.fullPathMatch - Whether to match full path (default: false)
613+
* @returns {Promise<RequestResponse>} Array of values (as strings)
614+
*/
615+
export async function getFrontmatterKeyValues(params: {
616+
frontmatterKey: string,
617+
noteType?: 'Notes' | 'Calendar' | 'All',
618+
caseSensitive?: boolean,
619+
folderString?: string,
620+
fullPathMatch?: boolean,
621+
}): Promise<RequestResponse> {
622+
const startTime: number = Date.now()
623+
try {
624+
logDebug(pluginJson, `[DIAG] getFrontmatterKeyValues START: frontmatterKey="${params.frontmatterKey}"`)
625+
626+
if (!params.frontmatterKey) {
627+
return {
628+
success: false,
629+
message: 'Frontmatter key is required',
630+
data: [],
631+
}
632+
}
633+
634+
const noteType = params.noteType || 'All'
635+
const caseSensitive = params.caseSensitive || false
636+
const folderString = params.folderString || ''
637+
const fullPathMatch = params.fullPathMatch || false
638+
639+
// Get values using the helper function
640+
const values = await getValuesForFrontmatterTag(params.frontmatterKey, noteType, caseSensitive, folderString, fullPathMatch)
641+
642+
// Convert all values to strings (frontmatter values can be various types)
643+
let stringValues = values.map((v: any) => String(v))
644+
645+
// Filter out templating syntax values (containing "<%") - these are template code, not actual values
646+
// This prevents templating errors when forms load and process frontmatter
647+
const beforeFilterCount = stringValues.length
648+
stringValues = stringValues.filter((v: string) => !v.includes('<%'))
649+
if (beforeFilterCount !== stringValues.length) {
650+
logDebug(pluginJson, `[DIAG] getFrontmatterKeyValues: Filtered out ${beforeFilterCount - stringValues.length} templating syntax values`)
651+
}
652+
653+
const totalElapsed: number = Date.now() - startTime
654+
logDebug(pluginJson, `[DIAG] getFrontmatterKeyValues COMPLETE: totalElapsed=${totalElapsed}ms, found=${stringValues.length} values for key "${params.frontmatterKey}"`)
655+
656+
return {
657+
success: true,
658+
data: stringValues,
659+
}
660+
} catch (error) {
661+
const totalElapsed: number = Date.now() - startTime
662+
logError(pluginJson, `[DIAG] getFrontmatterKeyValues ERROR: totalElapsed=${totalElapsed}ms, error="${error.message}"`)
663+
return {
664+
success: false,
665+
message: `Failed to get frontmatter key values: ${error.message}`,
666+
data: [],
667+
}
668+
}
669+
}

dwertheimer.Forms/src/requestHandlers.js

Lines changed: 4 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import { getAllTeamspaceIDsAndTitles } from '@helpers/NPTeamspace'
2424
import { parseTeamspaceFilename } from '@helpers/teamspace'
2525
import { showMessage } from '@helpers/userInput'
2626
import { getHeadingsFromNote, getOrMakeRegularNoteInFolder } from '@helpers/NPnote'
27-
import { getValuesForFrontmatterTag } from '@helpers/NPFrontMatter'
2827
import { getNoteByFilename, getNote } from '@helpers/note'
2928
import { getNoteContentAsHTML } from '@helpers/HTMLView'
3029
import { focusHTMLWindowIfAvailable } from '@helpers/NPWindows'
@@ -36,7 +35,7 @@ import { initPromisePolyfills, waitForCondition } from '@helpers/promisePolyfill
3635
import { testFormFieldRender } from './FormFieldRenderTest'
3736
// Import data-fetching functions from dataHandlers.js to break circular dependency
3837
// These are used by handleRequest() but not re-exported - import directly from dataHandlers.js if needed
39-
import { getFolders, getNotes, getEvents, getHashtags, getMentions, getTeamspaces } from './dataHandlers'
38+
import { getFolders, getNotes, getEvents, getHashtags, getMentions, getTeamspaces, getFrontmatterKeyValues } from './dataHandlers'
4039
// Import RequestResponse type from shared types
4140
import { type RequestResponse } from './shared/types'
4241
// Re-export RequestResponse type for backward compatibility (used by other handler files)
@@ -184,71 +183,9 @@ export function getAvailableReminderLists(_params: Object = {}): RequestResponse
184183
// getHashtags and getMentions have been moved to dataHandlers.js to break circular dependencies.
185184
// They are imported and re-exported above for backward compatibility.
186185

187-
/**
188-
* Get all values for a frontmatter key from DataStore
189-
* @param {Object} params - Request parameters
190-
* @param {string} params.frontmatterKey - The frontmatter key to get values for
191-
* @param {'Notes' | 'Calendar' | 'All'} params.noteType - Type of notes to search (default: 'All')
192-
* @param {boolean} params.caseSensitive - Whether to perform case-sensitive search (default: false)
193-
* @param {string} params.folderString - Folder to limit search to (optional)
194-
* @param {boolean} params.fullPathMatch - Whether to match full path (default: false)
195-
* @returns {Promise<RequestResponse>} Array of values (as strings)
196-
*/
197-
export async function getFrontmatterKeyValues(params: {
198-
frontmatterKey: string,
199-
noteType?: 'Notes' | 'Calendar' | 'All',
200-
caseSensitive?: boolean,
201-
folderString?: string,
202-
fullPathMatch?: boolean,
203-
}): Promise<RequestResponse> {
204-
const startTime: number = Date.now()
205-
try {
206-
logDebug(pluginJson, `[DIAG] getFrontmatterKeyValues START: frontmatterKey="${params.frontmatterKey}"`)
207-
208-
if (!params.frontmatterKey) {
209-
return {
210-
success: false,
211-
message: 'Frontmatter key is required',
212-
data: [],
213-
}
214-
}
215-
216-
const noteType = params.noteType || 'All'
217-
const caseSensitive = params.caseSensitive || false
218-
const folderString = params.folderString || ''
219-
const fullPathMatch = params.fullPathMatch || false
220-
221-
// Get values using the helper function
222-
const values = await getValuesForFrontmatterTag(params.frontmatterKey, noteType, caseSensitive, folderString, fullPathMatch)
223-
224-
// Convert all values to strings (frontmatter values can be various types)
225-
let stringValues = values.map((v: any) => String(v))
226-
227-
// Filter out templating syntax values (containing "<%") - these are template code, not actual values
228-
// This prevents templating errors when forms load and process frontmatter
229-
const beforeFilterCount = stringValues.length
230-
stringValues = stringValues.filter((v: string) => !v.includes('<%'))
231-
if (beforeFilterCount !== stringValues.length) {
232-
logDebug(pluginJson, `[DIAG] getFrontmatterKeyValues: Filtered out ${beforeFilterCount - stringValues.length} templating syntax values`)
233-
}
234-
235-
const totalElapsed: number = Date.now() - startTime
236-
logDebug(pluginJson, `[DIAG] getFrontmatterKeyValues COMPLETE: totalElapsed=${totalElapsed}ms, found=${stringValues.length} values for key "${params.frontmatterKey}"`)
237-
238-
return {
239-
success: true,
240-
data: stringValues,
241-
}
242-
} catch (error) {
243-
const totalElapsed: number = Date.now() - startTime
244-
logError(pluginJson, `[DIAG] getFrontmatterKeyValues ERROR: totalElapsed=${totalElapsed}ms, error="${error.message}"`)
245-
return {
246-
success: false,
247-
message: `Failed to get frontmatter key values: ${error.message}`,
248-
data: [],
249-
}
250-
}
251-
}
186+
// getFrontmatterKeyValues has been moved to dataHandlers.js to break circular dependency:
187+
// requestHandlers -> FormFieldRenderTest -> windowManagement -> requestHandlers
188+
export { getFrontmatterKeyValues } from './dataHandlers'
252189

253190
// getTeamspaces has been moved to dataHandlers.js to break circular dependencies.
254191
// It is imported and re-exported above for backward compatibility.

dwertheimer.Forms/src/windowManagement.js

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import pluginJson from '../plugin.json'
77
import { type PassedData } from './shared/types.js'
88
import { FORMBUILDER_WINDOW_ID, WEBVIEW_WINDOW_ID } from './shared/constants.js'
99
import { loadTemplateBodyFromTemplate, loadTemplateRunnerArgsFromTemplate, loadCustomCSSFromTemplate, loadNewNoteFrontmatterFromTemplate } from './templateIO.js'
10-
import { getFolders, getNotes, getTeamspaces, getMentions, getHashtags, getEvents } from './dataHandlers'
10+
import { getFolders, getNotes, getTeamspaces, getMentions, getHashtags, getEvents, getFrontmatterKeyValues } from './dataHandlers'
1111
import { closeWindowFromCustomId } from '@helpers/NPWindows'
1212
import { generateCSSFromTheme } from '@helpers/NPThemeToCSS'
1313
import { logDebug, logError, timer, JSP, clo } from '@helpers/dev'
@@ -461,7 +461,6 @@ async function preloadFrontmatterKeyValues(pluginData: Object, frontmatterKeys:
461461
}
462462

463463
try {
464-
const { getFrontmatterKeyValues } = await import('./requestHandlers.js')
465464
const preloadedValues: { [string]: Array<string> } = {}
466465

467466
// Preload values for each unique frontmatter key
@@ -501,7 +500,6 @@ async function preloadFrontmatterKeyValues(pluginData: Object, frontmatterKeys:
501500
*/
502501
async function preloadAllChooserData(pluginData: Object, requirements: Object): Promise<void> {
503502
logDebug(pluginJson, `preloadAllChooserData: Loading chooser data upfront for static HTML testing`)
504-
505503
preloadFolders(pluginData, requirements.needsFolders)
506504
preloadNotes(pluginData, requirements.needsNotes)
507505
preloadTeamspaces(pluginData, requirements.needsSpaces)
@@ -597,13 +595,13 @@ export async function openFormWindow(argObj: Object): Promise<void> {
597595

598596
// Generate base customId (without random suffix) for searching existing windows
599597
const baseCustomId = getFormWindowId(argObj?.formTitle || argObj?.windowTitle || '')
600-
598+
601599
// IMPORTANT: If we re-open a form into an existing HTML window with the same base `customId`,
602-
// NotePlan may keep the window around after close and reuse it, replacing its HTML/JS. We've seen recurring
603-
// native crashes in JavaScriptCore (`EXC_BAD_ACCESS` in `JSC::JSRunLoopTimer::Manager::timerDidFireCallback`)
604-
// when a window is "reloaded" this way, likely due to pending timers/cleanup in the old WebView/React instance
600+
// NotePlan may keep the window around after close and reuse it, replacing its HTML/JS. We've seen recurring
601+
// native crashes in JavaScriptCore (`EXC_BAD_ACCESS` in `JSC::JSRunLoopTimer::Manager::timerDidFireCallback`)
602+
// when a window is "reloaded" this way, likely due to pending timers/cleanup in the old WebView/React instance
605603
// racing with the new load.
606-
// Mitigation:
604+
// Mitigation:
607605
// 1. Search for any existing windows that START WITH the base customId and close them
608606
// 2. Append a random suffix to make the customId unique, preventing window reuse
609607
// 3. Store the unique windowId in pluginData so backend receives correct ID (window sends __windowId in requests)
@@ -614,7 +612,10 @@ export async function openFormWindow(argObj: Object): Promise<void> {
614612
}
615613
}
616614
if (windowsToClose.length > 0) {
617-
logDebug(pluginJson, `openFormWindow: Found ${windowsToClose.length} existing form window(s) with base customId="${baseCustomId}", closing them before opening new window to avoid JSC crash`)
615+
logDebug(
616+
pluginJson,
617+
`openFormWindow: Found ${windowsToClose.length} existing form window(s) with base customId="${baseCustomId}", closing them before opening new window to avoid JSC crash`,
618+
)
618619
for (const customIdToClose of windowsToClose) {
619620
closeWindowFromCustomId(customIdToClose)
620621
}
@@ -625,7 +626,7 @@ export async function openFormWindow(argObj: Object): Promise<void> {
625626
const randomSuffix = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
626627
const uniqueCustomId = `${baseCustomId}-${randomSuffix}`
627628
logDebug(pluginJson, `openFormWindow: Generated unique customId="${uniqueCustomId}" from base="${baseCustomId}"`)
628-
629+
629630
// Pass the unique windowId to createWindowInitData so it's stored in pluginData.windowId
630631
// This ensures the window sends the correct __windowId to backend (backend uses __windowId from request, not customId)
631632
const argObjWithUniqueWindowId = {

helpers/react/DynamicDialog/GenericDatePicker.jsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -166,23 +166,7 @@ const GenericDatePicker = ({ onSelectDate, startingSelectedDate, disabled = fals
166166
if (valueChanged) {
167167
lastSentValueRef.current = formatted
168168
onSelectDate(formatted)
169-
// Optionally close native calendar after picker selection (not while typing)
170-
if (inputRef.current) {
171-
requestAnimationFrame(() => {
172-
requestAnimationFrame(() => {
173-
setTimeout(() => {
174-
const el = inputRef.current
175-
if (el && document.activeElement === el) {
176-
el.blur()
177-
const wrapper = el.closest && el.closest('.generic-date-picker-wrapper')
178-
if (wrapper && wrapper.parentElement) {
179-
wrapper.parentElement.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }))
180-
}
181-
}
182-
}, 50)
183-
})
184-
})
185-
}
169+
// Do not blur here: blurring after typing a date stole focus and broke tab order through the form. User can Tab to next field or close calendar by clicking outside.
186170
}
187171
}
188172
// Empty or unparseable: do nothing. Input is uncontrolled so DOM keeps user's typing. Clear only on blur or Clear button.
@@ -221,15 +205,11 @@ const GenericDatePicker = ({ onSelectDate, startingSelectedDate, disabled = fals
221205
}
222206
}
223207

224-
// Handle Enter key to prevent form submission
208+
// Handle Enter key: prevent form submission but do not blur (keeping focus so user can Tab to next field)
225209
const handleKeyDown = (e: SyntheticKeyboardEvent<HTMLInputElement>) => {
226210
if (e.key === 'Enter') {
227211
e.preventDefault()
228212
e.stopPropagation()
229-
// Blur the input to trigger validation
230-
if (inputRef.current) {
231-
inputRef.current.blur()
232-
}
233213
}
234214
}
235215

0 commit comments

Comments
 (0)