From 8e006e59ce7616222f188c518928d77010e412ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20Hern=C3=A1ndez?= Date: Thu, 21 May 2026 22:05:01 -0600 Subject: [PATCH 1/7] Fixing issue when cookie and filter control are enabled --- .../bootstrap-table-filter-control.js | 11 +- tests/extensions/filter-control.test.js | 165 ++++++++++++++++++ 2 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 tests/extensions/filter-control.test.js diff --git a/src/extensions/filter-control/bootstrap-table-filter-control.js b/src/extensions/filter-control/bootstrap-table-filter-control.js index 1e564e748..bd8c542d4 100644 --- a/src/extensions/filter-control/bootstrap-table-filter-control.js +++ b/src/extensions/filter-control/bootstrap-table-filter-control.js @@ -60,7 +60,8 @@ Object.assign($.fn.bootstrapTable.defaults, { _valuesFilterControl: [], _initialized: false, _isRendering: false, - _usingMultipleSelect: false + _usingMultipleSelect: false, + _isFilterControlInitialRender: false }) Object.assign($.fn.bootstrapTable.columnDefaults, { @@ -126,6 +127,7 @@ $.BootstrapTable = class extends $.BootstrapTable { this._initialized = false this._usingMultipleSelect = false this._isRendering = false + this._isFilterControlInitialRender = false this.$el .on('reset-view.bs.table', Utils.debounce(() => { @@ -192,8 +194,13 @@ $.BootstrapTable = class extends $.BootstrapTable { return } + this._isFilterControlInitialRender = true UtilsFilterControl.createControls(this, UtilsFilterControl.getControlContainer(this)) this._initialized = true + + setTimeout(() => { + this._isFilterControlInitialRender = false + }, this.options.searchTimeOut + 50) } initSearch () { @@ -484,7 +491,7 @@ $.BootstrapTable = class extends $.BootstrapTable { } UtilsFilterControl.cacheValues(this) - const isInitialRender = !this._initialized + const isInitialRender = !this._initialized || this._isFilterControlInitialRender // Cookie extension support if (!this.options.cookie) { diff --git a/tests/extensions/filter-control.test.js b/tests/extensions/filter-control.test.js new file mode 100644 index 000000000..ea44287b6 --- /dev/null +++ b/tests/extensions/filter-control.test.js @@ -0,0 +1,165 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { beforeAll, describe, expect, it, vi } from 'vitest' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const SOURCE_PATH = path.resolve( + __dirname, + '../../src/extensions/filter-control/bootstrap-table-filter-control.js' +) + +describe('filter-control issue #8246', () => { + describe('source regression guards', () => { + let source + + beforeAll(() => { + source = fs.readFileSync(SOURCE_PATH, 'utf-8') + }) + + it('declares _isFilterControlInitialRender in defaults', () => { + expect(source).toMatch(/_isFilterControlInitialRender:\s*false/) + }) + + it('resets _isFilterControlInitialRender in init()', () => { + expect(source).toMatch(/this\._isFilterControlInitialRender\s*=\s*false/) + }) + + it('initHeader() sets the flag, schedules a cleanup, and keeps _initialized = true', () => { + const match = source.match(/initHeader\s*\(\s*\)\s*\{[\s\S]*?\n {2}\}/) + + expect(match, 'initHeader block must be present').not.toBeNull() + const initHeaderBody = match[0] + + expect(initHeaderBody).toMatch(/this\._isFilterControlInitialRender\s*=\s*true/) + expect(initHeaderBody).toMatch(/this\._initialized\s*=\s*true/) + expect(initHeaderBody).toMatch(/setTimeout\s*\(/) + expect(initHeaderBody).toMatch(/this\.options\.searchTimeOut\s*\+\s*50/) + }) + + it('onColumnSearch derives isInitialRender from both flags', () => { + expect(source).toMatch( + /const isInitialRender = !this\._initialized \|\| this\._isFilterControlInitialRender/ + ) + }) + }) + + describe('isInitialRender truth table', () => { + const computeIsInitialRender = state => + !state._initialized || state._isFilterControlInitialRender + + it('treats pre-initialization as initial render', () => { + expect( + computeIsInitialRender({ _initialized: false, _isFilterControlInitialRender: false }) + ).toBe(true) + }) + + it('treats the initHeader grace window as initial render (the #8246 fix)', () => { + expect( + computeIsInitialRender({ _initialized: true, _isFilterControlInitialRender: true }) + ).toBe(true) + }) + + it('treats user-initiated searches after the grace window as not initial', () => { + expect( + computeIsInitialRender({ _initialized: true, _isFilterControlInitialRender: false }) + ).toBe(false) + }) + + it('treats not-initialized state as initial even if the flag is somehow true', () => { + expect( + computeIsInitialRender({ _initialized: false, _isFilterControlInitialRender: true }) + ).toBe(true) + }) + }) + + describe('onColumnSearch effect simulation', () => { + function simulateOnColumnSearch (state) { + const ctx = { + _initialized: state._initialized, + _isFilterControlInitialRender: state._isFilterControlInitialRender, + _filterControlValuesLoaded: state._filterControlValuesLoaded ?? false, + options: { + cookie: state.cookie, + pageNumber: state.pageNumber + }, + onSearch: vi.fn() + } + + const isInitialRender = !ctx._initialized || ctx._isFilterControlInitialRender + + if (!ctx.options.cookie) { + if (!isInitialRender) { + ctx.options.pageNumber = 1 + } + } else { + ctx._filterControlValuesLoaded = true + } + + ctx.onSearch({ currentTarget: null, firedByInitSearchText: isInitialRender }, false) + return ctx + } + + it('issue #8246: cookie-restored pageNumber survives the deferred initial-render call', () => { + const ctx = simulateOnColumnSearch({ + _initialized: true, + _isFilterControlInitialRender: true, + cookie: true, + pageNumber: 5 + }) + + expect(ctx.options.pageNumber).toBe(5) + expect(ctx._filterControlValuesLoaded).toBe(true) + expect(ctx.onSearch).toHaveBeenCalledWith( + expect.objectContaining({ firedByInitSearchText: true }), + false + ) + }) + + it('user-initiated cookie+filter search after the grace window does NOT reset pageNumber inside filter-control, but signals core onSearch to reset', () => { + const ctx = simulateOnColumnSearch({ + _initialized: true, + _isFilterControlInitialRender: false, + cookie: true, + pageNumber: 5 + }) + + expect(ctx.options.pageNumber).toBe(5) + expect(ctx._filterControlValuesLoaded).toBe(true) + expect(ctx.onSearch).toHaveBeenCalledWith( + expect.objectContaining({ firedByInitSearchText: false }), + false + ) + }) + + it('non-cookie user search after the grace window resets pageNumber to 1', () => { + const ctx = simulateOnColumnSearch({ + _initialized: true, + _isFilterControlInitialRender: false, + cookie: false, + pageNumber: 5 + }) + + expect(ctx.options.pageNumber).toBe(1) + expect(ctx.onSearch).toHaveBeenCalledWith( + expect.objectContaining({ firedByInitSearchText: false }), + false + ) + }) + + it('non-cookie initial render does not reset pageNumber', () => { + const ctx = simulateOnColumnSearch({ + _initialized: false, + _isFilterControlInitialRender: false, + cookie: false, + pageNumber: 5 + }) + + expect(ctx.options.pageNumber).toBe(5) + expect(ctx.onSearch).toHaveBeenCalledWith( + expect.objectContaining({ firedByInitSearchText: true }), + false + ) + }) + }) +}) From f93e5774f0f57899071c7f6869bc9df716225674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20Hern=C3=A1ndez?= Date: Thu, 21 May 2026 22:41:55 -0600 Subject: [PATCH 2/7] Add missing file --- .../bootstrap-table-filter-control.js | 19 +--- src/extensions/filter-control/utils.js | 10 +- tests/extensions/filter-control.test.js | 105 +++++++++++------- 3 files changed, 74 insertions(+), 60 deletions(-) diff --git a/src/extensions/filter-control/bootstrap-table-filter-control.js b/src/extensions/filter-control/bootstrap-table-filter-control.js index bd8c542d4..9c3b37fa6 100644 --- a/src/extensions/filter-control/bootstrap-table-filter-control.js +++ b/src/extensions/filter-control/bootstrap-table-filter-control.js @@ -60,8 +60,7 @@ Object.assign($.fn.bootstrapTable.defaults, { _valuesFilterControl: [], _initialized: false, _isRendering: false, - _usingMultipleSelect: false, - _isFilterControlInitialRender: false + _usingMultipleSelect: false }) Object.assign($.fn.bootstrapTable.columnDefaults, { @@ -127,7 +126,6 @@ $.BootstrapTable = class extends $.BootstrapTable { this._initialized = false this._usingMultipleSelect = false this._isRendering = false - this._isFilterControlInitialRender = false this.$el .on('reset-view.bs.table', Utils.debounce(() => { @@ -194,13 +192,8 @@ $.BootstrapTable = class extends $.BootstrapTable { return } - this._isFilterControlInitialRender = true UtilsFilterControl.createControls(this, UtilsFilterControl.getControlContainer(this)) this._initialized = true - - setTimeout(() => { - this._isFilterControlInitialRender = false - }, this.options.searchTimeOut + 50) } initSearch () { @@ -485,13 +478,13 @@ $.BootstrapTable = class extends $.BootstrapTable { } // EVENTS - onColumnSearch ({ currentTarget, keyCode }) { + onColumnSearch ({ currentTarget, keyCode, isInitial }) { if (UtilsFilterControl.isKeyAllowed(keyCode)) { return } UtilsFilterControl.cacheValues(this) - const isInitialRender = !this._initialized || this._isFilterControlInitialRender + const isInitialRender = !this._initialized || isInitial === true // Cookie extension support if (!this.options.cookie) { @@ -555,16 +548,16 @@ $.BootstrapTable = class extends $.BootstrapTable { .html(`${Utils.sprintf(this.constants.html.icon, this.options.iconsPrefix, icon)} ${text}`) } - triggerSearch () { + triggerSearch (isInitial = false) { const searchControls = UtilsFilterControl.getSearchControls(this) searchControls.each(function () { const $element = $(this) if ($element.is('select')) { - $element.trigger('change') + $element.trigger('change', { isInitial }) } else { - $element.trigger('keyup') + $element.trigger('keyup', { isInitial }) } }) } diff --git a/src/extensions/filter-control/utils.js b/src/extensions/filter-control/utils.js index f1cbccaf0..def9fe814 100644 --- a/src/extensions/filter-control/utils.js +++ b/src/extensions/filter-control/utils.js @@ -449,6 +449,7 @@ export function createControls (that, header) { if (addedFilterControl) { header.off('keyup', 'input').on('keyup', 'input', ({ currentTarget, keyCode }, obj) => { keyCode = obj ? obj.keyCode : keyCode + const isInitial = !!(obj && obj.isInitial) if (that.options.searchOnEnterKey && keyCode !== 13) { return @@ -466,13 +467,14 @@ export function createControls (that, header) { clearTimeout(currentTarget.timeoutId || 0) currentTarget.timeoutId = setTimeout(() => { - that.onColumnSearch({ currentTarget, keyCode }) + that.onColumnSearch({ currentTarget, keyCode, isInitial }) }, that.options.searchTimeOut) }) - header.off('change', 'select').on('change', 'select', ({ currentTarget, keyCode }) => { + header.off('change', 'select').on('change', 'select', ({ currentTarget, keyCode }, obj) => { const $selectControl = $(currentTarget) const value = $selectControl.val() + const isInitial = !!(obj && obj.isInitial) const normalizedValue = value === null ? $selectControl.prop('multiple') ? [] : null : @@ -482,7 +484,7 @@ export function createControls (that, header) { clearTimeout(currentTarget.timeoutId || 0) currentTarget.timeoutId = setTimeout(() => { - that.onColumnSearch({ currentTarget, keyCode }) + that.onColumnSearch({ currentTarget, keyCode, isInitial }) }, that.options.searchTimeOut) }) @@ -550,7 +552,7 @@ export function createControls (that, header) { } if (that.options.sidePagination !== 'server') { - that.triggerSearch() + that.triggerSearch(true) } if (!that.options.filterControlVisible) { diff --git a/tests/extensions/filter-control.test.js b/tests/extensions/filter-control.test.js index ea44287b6..54e149fb3 100644 --- a/tests/extensions/filter-control.test.js +++ b/tests/extensions/filter-control.test.js @@ -4,72 +4,92 @@ import { fileURLToPath } from 'node:url' import { beforeAll, describe, expect, it, vi } from 'vitest' const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const SOURCE_PATH = path.resolve( +const FILTER_CONTROL_PATH = path.resolve( __dirname, '../../src/extensions/filter-control/bootstrap-table-filter-control.js' ) +const FILTER_CONTROL_UTILS_PATH = path.resolve( + __dirname, + '../../src/extensions/filter-control/utils.js' +) describe('filter-control issue #8246', () => { describe('source regression guards', () => { - let source + let mainSource + let utilsSource beforeAll(() => { - source = fs.readFileSync(SOURCE_PATH, 'utf-8') + mainSource = fs.readFileSync(FILTER_CONTROL_PATH, 'utf-8') + utilsSource = fs.readFileSync(FILTER_CONTROL_UTILS_PATH, 'utf-8') }) - it('declares _isFilterControlInitialRender in defaults', () => { - expect(source).toMatch(/_isFilterControlInitialRender:\s*false/) + it('triggerSearch() accepts an isInitial parameter and forwards it via trigger data', () => { + const match = mainSource.match(/triggerSearch\s*\([^)]*\)\s*\{[\s\S]*?\n {2}\}/) + + expect(match, 'triggerSearch block must be present').not.toBeNull() + const body = match[0] + + expect(body).toMatch(/triggerSearch\s*\(\s*isInitial\s*=\s*false\s*\)/) + expect(body).toMatch(/\.trigger\('change',\s*\{\s*isInitial\s*\}\)/) + expect(body).toMatch(/\.trigger\('keyup',\s*\{\s*isInitial\s*\}\)/) }) - it('resets _isFilterControlInitialRender in init()', () => { - expect(source).toMatch(/this\._isFilterControlInitialRender\s*=\s*false/) + it('createControls calls triggerSearch(true) for the initial render', () => { + expect(utilsSource).toMatch(/that\.triggerSearch\s*\(\s*true\s*\)/) }) - it('initHeader() sets the flag, schedules a cleanup, and keeps _initialized = true', () => { - const match = source.match(/initHeader\s*\(\s*\)\s*\{[\s\S]*?\n {2}\}/) + it('keyup handler reads isInitial from event data and forwards to onColumnSearch', () => { + // capture from the keyup binding up to (but not including) the next binding + const m = utilsSource.match(/header\.off\('keyup',\s*'input'\)[\s\S]*?(?=header\.off\(|$)/) - expect(match, 'initHeader block must be present').not.toBeNull() - const initHeaderBody = match[0] + expect(m, 'keyup handler must be present').not.toBeNull() + const body = m[0] - expect(initHeaderBody).toMatch(/this\._isFilterControlInitialRender\s*=\s*true/) - expect(initHeaderBody).toMatch(/this\._initialized\s*=\s*true/) - expect(initHeaderBody).toMatch(/setTimeout\s*\(/) - expect(initHeaderBody).toMatch(/this\.options\.searchTimeOut\s*\+\s*50/) + expect(body).toMatch(/const isInitial\s*=\s*!!\(obj && obj\.isInitial\)/) + expect(body).toMatch(/onColumnSearch\(\{[^}]*isInitial[^}]*\}\)/) }) - it('onColumnSearch derives isInitialRender from both flags', () => { - expect(source).toMatch( - /const isInitialRender = !this\._initialized \|\| this\._isFilterControlInitialRender/ + it('change handler on select reads isInitial from event data and forwards to onColumnSearch', () => { + const m = utilsSource.match(/header\.off\('change',\s*'select'\)[\s\S]*?(?=header\.off\(|$)/) + + expect(m, 'select change handler must be present').not.toBeNull() + const body = m[0] + + expect(body).toMatch(/const isInitial\s*=\s*!!\(obj && obj\.isInitial\)/) + expect(body).toMatch(/onColumnSearch\(\{[^}]*isInitial[^}]*\}\)/) + }) + + it('onColumnSearch derives isInitialRender from _initialized OR the per-call isInitial flag', () => { + expect(mainSource).toMatch( + /onColumnSearch\s*\(\{[^}]*\bisInitial\b[^}]*\}\)/ + ) + expect(mainSource).toMatch( + /const isInitialRender = !this\._initialized \|\| isInitial === true/ ) }) }) describe('isInitialRender truth table', () => { - const computeIsInitialRender = state => - !state._initialized || state._isFilterControlInitialRender + const computeIsInitialRender = (initialized, isInitial) => + !initialized || isInitial === true it('treats pre-initialization as initial render', () => { - expect( - computeIsInitialRender({ _initialized: false, _isFilterControlInitialRender: false }) - ).toBe(true) + expect(computeIsInitialRender(false, false)).toBe(true) + expect(computeIsInitialRender(false, undefined)).toBe(true) }) - it('treats the initHeader grace window as initial render (the #8246 fix)', () => { - expect( - computeIsInitialRender({ _initialized: true, _isFilterControlInitialRender: true }) - ).toBe(true) + it('treats the post-init triggerSearch(true) deferred call as initial render (the #8246 fix)', () => { + expect(computeIsInitialRender(true, true)).toBe(true) }) - it('treats user-initiated searches after the grace window as not initial', () => { - expect( - computeIsInitialRender({ _initialized: true, _isFilterControlInitialRender: false }) - ).toBe(false) + it('treats user-initiated searches as not initial', () => { + expect(computeIsInitialRender(true, false)).toBe(false) + expect(computeIsInitialRender(true, undefined)).toBe(false) }) - it('treats not-initialized state as initial even if the flag is somehow true', () => { - expect( - computeIsInitialRender({ _initialized: false, _isFilterControlInitialRender: true }) - ).toBe(true) + it('only treats explicit isInitial === true as initial (defensive)', () => { + expect(computeIsInitialRender(true, 'yes')).toBe(false) + expect(computeIsInitialRender(true, 1)).toBe(false) }) }) @@ -77,7 +97,6 @@ describe('filter-control issue #8246', () => { function simulateOnColumnSearch (state) { const ctx = { _initialized: state._initialized, - _isFilterControlInitialRender: state._isFilterControlInitialRender, _filterControlValuesLoaded: state._filterControlValuesLoaded ?? false, options: { cookie: state.cookie, @@ -86,7 +105,7 @@ describe('filter-control issue #8246', () => { onSearch: vi.fn() } - const isInitialRender = !ctx._initialized || ctx._isFilterControlInitialRender + const isInitialRender = !ctx._initialized || state.isInitial === true if (!ctx.options.cookie) { if (!isInitialRender) { @@ -103,7 +122,7 @@ describe('filter-control issue #8246', () => { it('issue #8246: cookie-restored pageNumber survives the deferred initial-render call', () => { const ctx = simulateOnColumnSearch({ _initialized: true, - _isFilterControlInitialRender: true, + isInitial: true, cookie: true, pageNumber: 5 }) @@ -116,10 +135,10 @@ describe('filter-control issue #8246', () => { ) }) - it('user-initiated cookie+filter search after the grace window does NOT reset pageNumber inside filter-control, but signals core onSearch to reset', () => { + it('user-initiated cookie+filter search does NOT reset pageNumber inside filter-control, but signals core onSearch to reset', () => { const ctx = simulateOnColumnSearch({ _initialized: true, - _isFilterControlInitialRender: false, + isInitial: false, cookie: true, pageNumber: 5 }) @@ -132,10 +151,10 @@ describe('filter-control issue #8246', () => { ) }) - it('non-cookie user search after the grace window resets pageNumber to 1', () => { + it('non-cookie user search resets pageNumber to 1', () => { const ctx = simulateOnColumnSearch({ _initialized: true, - _isFilterControlInitialRender: false, + isInitial: false, cookie: false, pageNumber: 5 }) @@ -150,7 +169,7 @@ describe('filter-control issue #8246', () => { it('non-cookie initial render does not reset pageNumber', () => { const ctx = simulateOnColumnSearch({ _initialized: false, - _isFilterControlInitialRender: false, + isInitial: false, cookie: false, pageNumber: 5 }) From fba6fa93c280d371f6e077030e071eb9a45f609c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20Hern=C3=A1ndez?= Date: Mon, 25 May 2026 11:41:28 -0600 Subject: [PATCH 3/7] Fix comments from AI --- .../bootstrap-table-filter-control.js | 7 +- tests/extensions/filter-control.test.js | 122 +++++++++++++++++- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/src/extensions/filter-control/bootstrap-table-filter-control.js b/src/extensions/filter-control/bootstrap-table-filter-control.js index 9c3b37fa6..013e130ee 100644 --- a/src/extensions/filter-control/bootstrap-table-filter-control.js +++ b/src/extensions/filter-control/bootstrap-table-filter-control.js @@ -553,11 +553,12 @@ $.BootstrapTable = class extends $.BootstrapTable { searchControls.each(function () { const $element = $(this) + const eventName = $element.is('select') ? 'change' : 'keyup' - if ($element.is('select')) { - $element.trigger('change', { isInitial }) + if (isInitial) { + $element.trigger(eventName, { isInitial: true }) } else { - $element.trigger('keyup', { isInitial }) + $element.trigger(eventName) } }) } diff --git a/tests/extensions/filter-control.test.js b/tests/extensions/filter-control.test.js index 54e149fb3..25806b32f 100644 --- a/tests/extensions/filter-control.test.js +++ b/tests/extensions/filter-control.test.js @@ -23,15 +23,15 @@ describe('filter-control issue #8246', () => { utilsSource = fs.readFileSync(FILTER_CONTROL_UTILS_PATH, 'utf-8') }) - it('triggerSearch() accepts an isInitial parameter and forwards it via trigger data', () => { + it('triggerSearch() accepts an isInitial parameter and only attaches event data when truthy', () => { const match = mainSource.match(/triggerSearch\s*\([^)]*\)\s*\{[\s\S]*?\n {2}\}/) expect(match, 'triggerSearch block must be present').not.toBeNull() const body = match[0] expect(body).toMatch(/triggerSearch\s*\(\s*isInitial\s*=\s*false\s*\)/) - expect(body).toMatch(/\.trigger\('change',\s*\{\s*isInitial\s*\}\)/) - expect(body).toMatch(/\.trigger\('keyup',\s*\{\s*isInitial\s*\}\)/) + expect(body).toMatch(/\.trigger\(\s*eventName\s*,\s*\{\s*isInitial:\s*true\s*\}\s*\)/) + expect(body).toMatch(/\.trigger\(\s*eventName\s*\)/) }) it('createControls calls triggerSearch(true) for the initial render', () => { @@ -181,4 +181,120 @@ describe('filter-control issue #8246', () => { ) }) }) + + describe('cookie pageNumber persistence (the full reload-vs-filter chain)', () => { + // Simulates the call chain + // filter-control.onColumnSearch + // -> core.onSearch (modules/search.js:159) + // -> cookie.onSearch override (extensions/cookie/bootstrap-table-cookie.js) + // -> UtilsCookie.setCookie(pageNumber, options.pageNumber) + // + // The user-visible artifact is the value written to the `bs.table.pageNumber` + // cookie at the end. Bug #8246 is reproduced when that value becomes `1` + // after a reload that landed the user on page 2. + function simulateOnColumnSearchToCookie (state) { + const setCookie = vi.fn() + const ctx = { + _initialized: state._initialized, + _filterControlValuesLoaded: false, + searchText: '', + options: { + cookie: true, + pageNumber: state.pageNumber, + search: false + } + } + + // -- filter-control.onColumnSearch (the cookie branch) -- + const isInitialRender = !ctx._initialized || state.isInitial === true + + if (!ctx.options.cookie) { + if (!isInitialRender) { + ctx.options.pageNumber = 1 + } + } else { + ctx._filterControlValuesLoaded = true + } + + // -- core.onSearch — the actual gate that historically resets pageNumber -- + const firedByInitSearchText = isInitialRender + + if (!firedByInitSearchText) { + ctx.options.pageNumber = 1 + } + + // -- cookie.onSearch override saves whatever options.pageNumber is now -- + setCookie('bs.table.pageNumber', ctx.options.pageNumber) + + return { ctx, setCookie } + } + + it('reload on page 2 keeps the cookie at 2 (issue #8246)', () => { + const { ctx, setCookie } = simulateOnColumnSearchToCookie({ + _initialized: true, + isInitial: true, + pageNumber: 2 + }) + + expect(ctx.options.pageNumber).toBe(2) + expect(setCookie).toHaveBeenCalledWith('bs.table.pageNumber', 2) + expect(setCookie).not.toHaveBeenCalledWith('bs.table.pageNumber', 1) + }) + + it('user filter on page 2 saves 1 to the cookie (expected behaviour)', () => { + const { ctx, setCookie } = simulateOnColumnSearchToCookie({ + _initialized: true, + isInitial: false, + pageNumber: 2 + }) + + expect(ctx.options.pageNumber).toBe(1) + expect(setCookie).toHaveBeenCalledWith('bs.table.pageNumber', 1) + }) + + it('reload on page 5 keeps the cookie at 5 (no off-by-one or hardcoded page)', () => { + const { ctx, setCookie } = simulateOnColumnSearchToCookie({ + _initialized: true, + isInitial: true, + pageNumber: 5 + }) + + expect(ctx.options.pageNumber).toBe(5) + expect(setCookie).toHaveBeenCalledWith('bs.table.pageNumber', 5) + }) + + it('reload on page 1 keeps the cookie at 1 (idempotent on first page)', () => { + const { ctx, setCookie } = simulateOnColumnSearchToCookie({ + _initialized: true, + isInitial: true, + pageNumber: 1 + }) + + expect(ctx.options.pageNumber).toBe(1) + expect(setCookie).toHaveBeenCalledWith('bs.table.pageNumber', 1) + }) + + it('two consecutive reloads each preserve their pageNumber (#8246 second-F5 regression)', () => { + // First reload from cookie = 2 + const first = simulateOnColumnSearchToCookie({ + _initialized: true, + isInitial: true, + pageNumber: 2 + }) + + expect(first.ctx.options.pageNumber).toBe(2) + expect(first.setCookie).toHaveBeenLastCalledWith('bs.table.pageNumber', 2) + + // Second reload reads what the first reload saved + const cookieAfterFirst = first.setCookie.mock.calls.at(-1)[1] + const second = simulateOnColumnSearchToCookie({ + _initialized: true, + isInitial: true, + pageNumber: cookieAfterFirst + }) + + expect(second.ctx.options.pageNumber).toBe(2) + expect(second.setCookie).toHaveBeenLastCalledWith('bs.table.pageNumber', 2) + }) + }) }) From 23f6157d2b17aac39d641b9bba3a80d380f1ac3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20Hern=C3=A1ndez?= Date: Tue, 26 May 2026 14:14:45 -0800 Subject: [PATCH 4/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/extensions/filter-control/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/filter-control/utils.js b/src/extensions/filter-control/utils.js index def9fe814..358cce9bf 100644 --- a/src/extensions/filter-control/utils.js +++ b/src/extensions/filter-control/utils.js @@ -448,7 +448,7 @@ export function createControls (that, header) { if (addedFilterControl) { header.off('keyup', 'input').on('keyup', 'input', ({ currentTarget, keyCode }, obj) => { - keyCode = obj ? obj.keyCode : keyCode + keyCode = obj?.keyCode ?? keyCode const isInitial = !!(obj && obj.isInitial) if (that.options.searchOnEnterKey && keyCode !== 13) { From dc250e3556708924cc5ec7451a037cfa3a525cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20Hern=C3=A1ndez?= Date: Tue, 26 May 2026 14:15:21 -0800 Subject: [PATCH 5/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/extensions/filter-control.test.js | 143 +++++++++++++++--------- 1 file changed, 88 insertions(+), 55 deletions(-) diff --git a/tests/extensions/filter-control.test.js b/tests/extensions/filter-control.test.js index 25806b32f..6c79c5868 100644 --- a/tests/extensions/filter-control.test.js +++ b/tests/extensions/filter-control.test.js @@ -1,71 +1,104 @@ -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { beforeAll, describe, expect, it, vi } from 'vitest' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const FILTER_CONTROL_PATH = path.resolve( - __dirname, - '../../src/extensions/filter-control/bootstrap-table-filter-control.js' -) -const FILTER_CONTROL_UTILS_PATH = path.resolve( - __dirname, - '../../src/extensions/filter-control/utils.js' -) +import { describe, expect, it, vi } from 'vitest' -describe('filter-control issue #8246', () => { - describe('source regression guards', () => { - let mainSource - let utilsSource +async function loadFilterControlPrototype() { + await import('../../src/bootstrap-table.js') + await import('../../src/extensions/filter-control/bootstrap-table-filter-control.js') - beforeAll(() => { - mainSource = fs.readFileSync(FILTER_CONTROL_PATH, 'utf-8') - utilsSource = fs.readFileSync(FILTER_CONTROL_UTILS_PATH, 'utf-8') - }) + const $ = globalThis.jQuery || globalThis.$ - it('triggerSearch() accepts an isInitial parameter and only attaches event data when truthy', () => { - const match = mainSource.match(/triggerSearch\s*\([^)]*\)\s*\{[\s\S]*?\n {2}\}/) + expect($?.fn?.bootstrapTable?.Constructor).toBeTruthy() - expect(match, 'triggerSearch block must be present').not.toBeNull() - const body = match[0] + return { + $, + BootstrapTable: $.fn.bootstrapTable.Constructor + } +} - expect(body).toMatch(/triggerSearch\s*\(\s*isInitial\s*=\s*false\s*\)/) - expect(body).toMatch(/\.trigger\(\s*eventName\s*,\s*\{\s*isInitial:\s*true\s*\}\s*\)/) - expect(body).toMatch(/\.trigger\(\s*eventName\s*\)/) - }) - - it('createControls calls triggerSearch(true) for the initial render', () => { - expect(utilsSource).toMatch(/that\.triggerSearch\s*\(\s*true\s*\)/) - }) +describe('filter-control issue #8246', () => { + describe('runtime regression guards', () => { + it('triggerSearch() only attaches isInitial event data when requested', async () => { + const { BootstrapTable } = await loadFilterControlPrototype() + const triggerSearch = BootstrapTable.prototype.triggerSearch + const context = { + $el: { + trigger: vi.fn() + }, + options: { + searchTimeOut: 0 + } + } - it('keyup handler reads isInitial from event data and forwards to onColumnSearch', () => { - // capture from the keyup binding up to (but not including) the next binding - const m = utilsSource.match(/header\.off\('keyup',\s*'input'\)[\s\S]*?(?=header\.off\(|$)/) + vi.useFakeTimers() - expect(m, 'keyup handler must be present').not.toBeNull() - const body = m[0] + try { + triggerSearch.call(context, true) + vi.runAllTimers() - expect(body).toMatch(/const isInitial\s*=\s*!!\(obj && obj\.isInitial\)/) - expect(body).toMatch(/onColumnSearch\(\{[^}]*isInitial[^}]*\}\)/) - }) + expect(context.$el.trigger).toHaveBeenCalledTimes(1) + expect(context.$el.trigger.mock.calls[0]).toHaveLength(2) + expect(context.$el.trigger.mock.calls[0][1]).toEqual({ isInitial: true }) - it('change handler on select reads isInitial from event data and forwards to onColumnSearch', () => { - const m = utilsSource.match(/header\.off\('change',\s*'select'\)[\s\S]*?(?=header\.off\(|$)/) + context.$el.trigger.mockClear() - expect(m, 'select change handler must be present').not.toBeNull() - const body = m[0] + triggerSearch.call(context) + vi.runAllTimers() - expect(body).toMatch(/const isInitial\s*=\s*!!\(obj && obj\.isInitial\)/) - expect(body).toMatch(/onColumnSearch\(\{[^}]*isInitial[^}]*\}\)/) + expect(context.$el.trigger).toHaveBeenCalledTimes(1) + expect(context.$el.trigger.mock.calls[0]).toHaveLength(1) + } finally { + vi.useRealTimers() + } }) - it('onColumnSearch derives isInitialRender from _initialized OR the per-call isInitial flag', () => { - expect(mainSource).toMatch( - /onColumnSearch\s*\(\{[^}]*\bisInitial\b[^}]*\}\)/ - ) - expect(mainSource).toMatch( - /const isInitialRender = !this\._initialized \|\| isInitial === true/ - ) + it('marks only the initial filter-control triggerSearch call as initial', async () => { + const { $, BootstrapTable } = await loadFilterControlPrototype() + const triggerSearchSpy = vi.spyOn(BootstrapTable.prototype, 'triggerSearch') + + document.body.innerHTML = '
' + + const $table = $('#issue-8246-table') + + try { + $table.bootstrapTable({ + search: true, + filterControl: true, + columns: [ + { + field: 'name', + title: 'Name', + filterControl: 'input' + } + ], + data: [ + { name: 'alpha' }, + { name: 'beta' } + ] + }) + + expect(triggerSearchSpy).toHaveBeenCalledWith(true) + + const callsAfterInit = triggerSearchSpy.mock.calls.length + const $input = $table.closest('.bootstrap-table').find('thead input').first() + + expect($input.length).toBe(1) + + $input.val('alp') + $input.trigger('keyup') + + expect(triggerSearchSpy.mock.calls.length).toBeGreaterThan(callsAfterInit) + expect( + triggerSearchSpy.mock.calls + .slice(callsAfterInit) + .some(args => args[0] === true) + ).toBe(false) + } finally { + if ($table.data('bootstrap.table')) { + $table.bootstrapTable('destroy') + } + + triggerSearchSpy.mockRestore() + document.body.innerHTML = '' + } }) }) From cae4f373645250eb07d409de206b886a88297db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20Hern=C3=A1ndez?= Date: Wed, 27 May 2026 10:42:53 -0600 Subject: [PATCH 6/7] Fix lint --- tests/extensions/filter-control.test.js | 133 +++++++++++++----------- 1 file changed, 74 insertions(+), 59 deletions(-) diff --git a/tests/extensions/filter-control.test.js b/tests/extensions/filter-control.test.js index 6c79c5868..9be248627 100644 --- a/tests/extensions/filter-control.test.js +++ b/tests/extensions/filter-control.test.js @@ -1,83 +1,106 @@ import { describe, expect, it, vi } from 'vitest' -async function loadFilterControlPrototype() { +async function loadFilterControlPrototype () { + if (!globalThis.$) { + const { default: jq } = await import('jquery') + + globalThis.$ = jq + globalThis.jQuery = jq + } await import('../../src/bootstrap-table.js') await import('../../src/extensions/filter-control/bootstrap-table-filter-control.js') const $ = globalThis.jQuery || globalThis.$ - expect($?.fn?.bootstrapTable?.Constructor).toBeTruthy() + expect($?.BootstrapTable).toBeTruthy() return { $, - BootstrapTable: $.fn.bootstrapTable.Constructor + BootstrapTable: $.BootstrapTable } } describe('filter-control issue #8246', () => { describe('runtime regression guards', () => { it('triggerSearch() only attaches isInitial event data when requested', async () => { - const { BootstrapTable } = await loadFilterControlPrototype() - const triggerSearch = BootstrapTable.prototype.triggerSearch - const context = { - $el: { - trigger: vi.fn() - }, - options: { - searchTimeOut: 0 - } - } + const { $ } = await loadFilterControlPrototype() - vi.useFakeTimers() + document.body.innerHTML = '
' + const $table = $('#ts-test') - try { - triggerSearch.call(context, true) - vi.runAllTimers() + $table.bootstrapTable({ + filterControl: true, + columns: [{ field: 'a', title: 'A', filterControl: 'input' }], + data: [{ a: 'x' }] + }) + + const triggerSpy = vi.spyOn($.fn, 'trigger') - expect(context.$el.trigger).toHaveBeenCalledTimes(1) - expect(context.$el.trigger.mock.calls[0]).toHaveLength(2) - expect(context.$el.trigger.mock.calls[0][1]).toEqual({ isInitial: true }) + try { + // triggerSearch(true) — every keyup/change must carry { isInitial: true } + triggerSpy.mockClear() + $table.bootstrapTable('triggerSearch', true) + + const initialEvents = triggerSpy.mock.calls.filter( + c => c[0] === 'keyup' || c[0] === 'change' + ) + + expect(initialEvents.length).toBeGreaterThan(0) + initialEvents.forEach(call => { + expect(call).toHaveLength(2) + expect(call[1]).toEqual({ isInitial: true }) + }) - context.$el.trigger.mockClear() + // triggerSearch() (no args) — must NOT attach extra event data, so the + // public API surface stays identical to the original signature. + triggerSpy.mockClear() + $table.bootstrapTable('triggerSearch') - triggerSearch.call(context) - vi.runAllTimers() + const userEvents = triggerSpy.mock.calls.filter( + c => c[0] === 'keyup' || c[0] === 'change' + ) - expect(context.$el.trigger).toHaveBeenCalledTimes(1) - expect(context.$el.trigger.mock.calls[0]).toHaveLength(1) + expect(userEvents.length).toBeGreaterThan(0) + userEvents.forEach(call => { + expect(call).toHaveLength(1) + }) } finally { - vi.useRealTimers() + triggerSpy.mockRestore() + if ($table.data('bootstrap.table')) { + $table.bootstrapTable('destroy') + } + document.body.innerHTML = '' } }) - it('marks only the initial filter-control triggerSearch call as initial', async () => { + it('only the initial onColumnSearch call carries isInitial: true; user input does not', async () => { const { $, BootstrapTable } = await loadFilterControlPrototype() - const triggerSearchSpy = vi.spyOn(BootstrapTable.prototype, 'triggerSearch') + const onColumnSearchSpy = vi.spyOn(BootstrapTable.prototype, 'onColumnSearch') document.body.innerHTML = '
' - const $table = $('#issue-8246-table') try { $table.bootstrapTable({ search: true, filterControl: true, - columns: [ - { - field: 'name', - title: 'Name', - filterControl: 'input' - } - ], - data: [ - { name: 'alpha' }, - { name: 'beta' } - ] + searchTimeOut: 0, + columns: [{ field: 'name', title: 'Name', filterControl: 'input' }], + data: [{ name: 'alpha' }, { name: 'beta' }] }) - expect(triggerSearchSpy).toHaveBeenCalledWith(true) + // Let the deferred onColumnSearch calls fired by the initial + // triggerSearch(true) drain. + await new Promise(resolve => setTimeout(resolve, 20)) + + const initialCalls = onColumnSearchSpy.mock.calls.slice() + const initialWithFlag = initialCalls.filter(c => c[0]?.isInitial === true) - const callsAfterInit = triggerSearchSpy.mock.calls.length + expect(initialWithFlag.length).toBeGreaterThan(0) + + // Now simulate a user keystroke and assert no subsequent + // onColumnSearch is flagged as initial. + onColumnSearchSpy.mockClear() const $input = $table.closest('.bootstrap-table').find('thead input').first() expect($input.length).toBe(1) @@ -85,18 +108,19 @@ describe('filter-control issue #8246', () => { $input.val('alp') $input.trigger('keyup') - expect(triggerSearchSpy.mock.calls.length).toBeGreaterThan(callsAfterInit) - expect( - triggerSearchSpy.mock.calls - .slice(callsAfterInit) - .some(args => args[0] === true) - ).toBe(false) + await new Promise(resolve => setTimeout(resolve, 20)) + + const userCalls = onColumnSearchSpy.mock.calls.slice() + + expect(userCalls.length).toBeGreaterThan(0) + userCalls.forEach(call => { + expect(call[0]?.isInitial).not.toBe(true) + }) } finally { + onColumnSearchSpy.mockRestore() if ($table.data('bootstrap.table')) { $table.bootstrapTable('destroy') } - - triggerSearchSpy.mockRestore() document.body.innerHTML = '' } }) @@ -216,15 +240,6 @@ describe('filter-control issue #8246', () => { }) describe('cookie pageNumber persistence (the full reload-vs-filter chain)', () => { - // Simulates the call chain - // filter-control.onColumnSearch - // -> core.onSearch (modules/search.js:159) - // -> cookie.onSearch override (extensions/cookie/bootstrap-table-cookie.js) - // -> UtilsCookie.setCookie(pageNumber, options.pageNumber) - // - // The user-visible artifact is the value written to the `bs.table.pageNumber` - // cookie at the end. Bug #8246 is reproduced when that value becomes `1` - // after a reload that landed the user on page 2. function simulateOnColumnSearchToCookie (state) { const setCookie = vi.fn() const ctx = { From 48277662abbbf85ec1eba7053639d3c21ac834c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20Hern=C3=A1ndez?= Date: Fri, 7 Aug 2026 22:13:45 -0600 Subject: [PATCH 7/7] Fix copilot comments --- package.json | 1 + src/extensions/filter-control/utils.js | 4 ++-- yarn.lock | 5 +++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 66870bc56..bf7b900b7 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "globals": "^17.6.0", "happy-dom": "^20.9.0", "headr": "^0.0.4", + "jquery": "^3.7.1", "npm-run-all2": "^9.0.0", "rimraf": "^6.1.3", "rollup": "^4.60.3", diff --git a/src/extensions/filter-control/utils.js b/src/extensions/filter-control/utils.js index 358cce9bf..5fd7c5502 100644 --- a/src/extensions/filter-control/utils.js +++ b/src/extensions/filter-control/utils.js @@ -449,7 +449,7 @@ export function createControls (that, header) { if (addedFilterControl) { header.off('keyup', 'input').on('keyup', 'input', ({ currentTarget, keyCode }, obj) => { keyCode = obj?.keyCode ?? keyCode - const isInitial = !!(obj && obj.isInitial) + const isInitial = obj?.isInitial === true if (that.options.searchOnEnterKey && keyCode !== 13) { return @@ -474,7 +474,7 @@ export function createControls (that, header) { header.off('change', 'select').on('change', 'select', ({ currentTarget, keyCode }, obj) => { const $selectControl = $(currentTarget) const value = $selectControl.val() - const isInitial = !!(obj && obj.isInitial) + const isInitial = obj?.isInitial === true const normalizedValue = value === null ? $selectControl.prop('multiple') ? [] : null : diff --git a/yarn.lock b/yarn.lock index d3ce16e31..a10b6b1a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4281,6 +4281,11 @@ jackspeak@^4.2.3: dependencies: "@isaacs/cliui" "^9.0.0" +jquery@^3.7.1: + version "3.7.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.7.1.tgz#083ef98927c9a6a74d05a6af02806566d16274de" + integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"