Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 30 additions & 19 deletions src/js/view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
*/

import React from 'react';
import {findDOMNode} from 'react-dom';
import Results from './result'
import {DefaultHoldingView} from './holding'
import {normalizeQuery, isEmptyQuery, fetchMapping} from '../api'
Expand Down Expand Up @@ -100,6 +99,13 @@ export default class Index extends React.Component<Props, State> {

requestUpdateURL: null | 'search' | 'filter';
resizeTimer: number | null | undefined;
boxRef = React.createRef<HTMLDivElement>();
freewordRef = React.createRef<HTMLInputElement>();
resultsRef = React.createRef<Results>();
/* removeEventListenerに同じ参照を渡すため、リスナーはここで束縛して持つ */
boundOnPopState = (e: PopStateEvent) => this.onPopState(e);
boundOnScroll = () => this.onScroll();
boundOnPressKey = (word: string) => this.onPressKey(word);

constructor(props: Props) {
super(props);
Expand Down Expand Up @@ -135,12 +141,12 @@ export default class Index extends React.Component<Props, State> {
}

componentDidMount() {
window.pressKey = this.onPressKey.bind(this);
window.pressKey = this.boundOnPressKey;
if (typeof history !== 'undefined' && history.pushState && history.state !== undefined) {
window.addEventListener('popstate', (e) => this.onPopState(e));
window.addEventListener('popstate', this.boundOnPopState);
}
window.addEventListener("scroll", this.onScroll.bind(this));
window.addEventListener("resize", this.onScroll.bind(this));
window.addEventListener("scroll", this.boundOnScroll);
window.addEventListener("resize", this.boundOnScroll);
if (!(this.props.region in this.state.mapping) || Object.keys(this.state.mapping[this.props.region].libraries).length === 0) {
fetchMapping(this.props.region, (res) => {
this.state.mapping[this.props.region] = res;
Expand All @@ -150,15 +156,18 @@ export default class Index extends React.Component<Props, State> {
}

componentWillUnmount() {
window.removeEventListener("scroll", this.onScroll);
window.removeEventListener("resize", this.onScroll);
window.removeEventListener('popstate', this.boundOnPopState);
window.removeEventListener("scroll", this.boundOnScroll);
window.removeEventListener("resize", this.boundOnScroll);
if (this.resizeTimer) clearTimeout(this.resizeTimer);
if (window.pressKey === this.boundOnPressKey) delete window.pressKey;
}

onScroll(e?: Event | React.SyntheticEvent) {
if (this.resizeTimer) clearTimeout(this.resizeTimer);
this.resizeTimer = window.setTimeout(() => {
let element = findDOMNode(this.refs.box);
if (element && element instanceof HTMLElement) {
let element = this.boxRef.current;
if (element) {
let rect = element.getBoundingClientRect();
let windowHeight: number = (window.innerHeight || 0);
this.setState({logoAvailable: windowHeight - 50 > rect.top + rect.height})
Expand All @@ -177,7 +186,8 @@ export default class Index extends React.Component<Props, State> {
query: normalizeQuery(params),
established_query: normalizeQuery(params)
});
(this.refs.results as any).setState({selected_id: getHash(), page: 0, sort_key: null, sort_order: ''});
/* sort_column: 従来はタイポでsort_keyを渡していて、ソート列がリセットされていなかった */
this.resultsRef.current?.setState({selected_id: getHash(), page: 0, sort_column: '', sort_order: ''});
}

doSearch(e: React.SyntheticEvent) {
Expand All @@ -197,7 +207,7 @@ export default class Index extends React.Component<Props, State> {
isbn: this.state.query.isbn ? this.state.query.isbn : ''
};
}
(this.refs.results as any).setState({selected_id: null, page: 0, sort_column: null, sort_order: ''});
this.resultsRef.current?.setState({selected_id: null, page: 0, sort_column: '', sort_order: ''});
this.setState({established_query: normalizeQuery(query)});
let onSearch = this.props.onSearch || null;
if (onSearch) onSearch(normalizeQuery(query));
Expand All @@ -212,7 +222,7 @@ export default class Index extends React.Component<Props, State> {
} else if (word === '[search]') {
let query: UnitradQuery;
query = {free: this.state.query.free ? this.state.query.free : ''};
(this.refs.results as any).setState({selected_id: null, page: 0, sort_column: null, sort_order: ''});
this.resultsRef.current?.setState({selected_id: null, page: 0, sort_column: '', sort_order: ''});
this.setState({established_query: normalizeQuery(query)});
let onSearch = this.props.onSearch || null;
if (onSearch) onSearch(normalizeQuery(query));
Expand All @@ -225,7 +235,8 @@ export default class Index extends React.Component<Props, State> {
freeword = window.jaco!.remove(freeword, /゜|゚|゚/g);
this.state.query.free = freeword;
this.setState({});
const elm = findDOMNode(this.refs.freeword) as any;
const elm = this.freewordRef.current as any;
if (!elm) return;
elm.focus();
if (elm.createTextRange) {
var range = elm.createTextRange();
Expand Down Expand Up @@ -283,7 +294,7 @@ export default class Index extends React.Component<Props, State> {
} else {
this.setState(newState as any);
}
(this.refs.results as any).setState({page: 0});
this.resultsRef.current?.setState({page: 0});
}

changeCustom(e: React.ChangeEvent<HTMLInputElement>) {
Expand Down Expand Up @@ -315,7 +326,7 @@ export default class Index extends React.Component<Props, State> {
if (history.pushState && history.state !== undefined) {
let query_string = buildQueryString(this.state.established_query, this.state.mode, this.state.filter);
if ('?' + location.search.split('?')[1] !== query_string) {
let hash = ((this.refs.results as any).state.selected_id && this.requestUpdateURL === 'filter') ? '#' + (this.refs.results as any).state.selected_id : '';
let hash = (this.resultsRef.current?.state.selected_id && this.requestUpdateURL === 'filter') ? '#' + this.resultsRef.current.state.selected_id : '';
history.pushState('search', '', location.pathname + query_string + hash);
}
}
Expand All @@ -325,12 +336,12 @@ export default class Index extends React.Component<Props, State> {
let form;
if (this.state.mode === 'simple') {
form = (
<div className="container" ref="box">
<div className="container" ref={this.boxRef}>
<div className="box">
<input type="search"
id="free"
autoFocus
ref="freeword"
ref={this.freewordRef}
aria-labelledby="searchButton"
value={this.state.query.free} onChange={this.updateHandler.bind(this)}
placeholder={this.props.freewordPlaceholder ? this.props.freewordPlaceholder : "フリーワード"}/>
Expand All @@ -350,7 +361,7 @@ export default class Index extends React.Component<Props, State> {
}
};
form = (
<div className="container" ref="box">
<div className="container" ref={this.boxRef}>
<div className="items">
<div>
<label htmlFor="title">タイトル</label>
Expand Down Expand Up @@ -426,7 +437,7 @@ export default class Index extends React.Component<Props, State> {
);
}
})()}
<Results ref="results"
<Results ref={this.resultsRef}
region={this.state.region}
mapping={this.state.mapping}
is_multiple_region={this.state.is_multiple_region}
Expand Down
10 changes: 5 additions & 5 deletions src/js/view/result.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,9 @@ export default class Results extends React.Component<Props, State> {
if (this.api) this.api.kill();
}

onSelectBook(e: React.ChangeEvent<HTMLInputElement>) {
onSelectBook(e: React.SyntheticEvent) {
if (window.getSelection().toString() !== '') return; // 選択中はクリックを処理しない
let current: Element | null | undefined = e.target;
let current: Element | null | undefined = e.target as Element;
while (current && current.parentNode) {
if (current.attributes.getNamedItem('data-id')) {
let hash = current.attributes.getNamedItem('data-id').value;
Expand Down Expand Up @@ -161,9 +161,9 @@ export default class Results extends React.Component<Props, State> {
this.setState({page: data.selected, selected_id: null});
}

onSort(e: React.ChangeEvent<HTMLInputElement>) {
onSort(e: React.SyntheticEvent) {
this.removeHash();
let target: null | Element & HTMLElement = e.target;
let target: null | Element & HTMLElement = e.target as Element & HTMLElement;
while (target && !target.className.match('sort')) {
target = target.parentElement;
}
Expand Down Expand Up @@ -198,7 +198,7 @@ export default class Results extends React.Component<Props, State> {
e = e || (window.event as any);
if (e.keyCode === 13) {
e.stopPropagation();
this.onSort(e as any);
this.onSort(e);
}
}

Expand Down
89 changes: 89 additions & 0 deletions test/test_view.mts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const g = globalThis as any;
g.window = dom.window;
g.document = dom.window.document;
g.location = dom.window.location;
/* Index の componentDidMount が popstate リスナーを登録する条件に history を見る */
g.history = dom.window.history;
/* navigator はNode 21以降 読み取り専用なので触らない。renderToStringでは使われない */
g.HTMLElement = dom.window.HTMLElement;
g.Element = dom.window.Element;
Expand Down Expand Up @@ -167,6 +169,93 @@ describe('Index(検索ボックス)', () => {
});
});

describe('Index(クライアント描画とライフサイクル)', () => {
/* libraries を渡しておくと componentDidMount の fetchMapping が通信を起こさない */
const base = {
region: 'test', mode: 'simple',
filters: [{id: 0, name: '全域', includes: []}],
libraries: {1: 'A図書館'}, name_to_id: {'A図書館': [1]}
};

before(() => setSearch(''));

function mountIndex() {
const container = dom.window.document.createElement('div');
dom.window.document.body.appendChild(container);
const root = createRoot(container);
let instance: any = null;
const ref = (r: any) => { if (r) instance = r; };
act(() => {
root.render(React.createElement(Index, {...base, ref} as any));
});
return {
instance,
unmount() {
act(() => { root.unmount(); });
container.remove();
}
};
}

it('resultsRefから結果一覧のインスタンスに触れる', () => {
const m = mountIndex();
assert.ok(m.instance.resultsRef.current);
m.unmount();
});

it('popstateで結果一覧の選択とソートをリセットする', () => {
const m = mountIndex();
const results = m.instance.resultsRef.current;
act(() => {
results.setState({selected_id: 'b1', page: 3, sort_column: 'title', sort_order: 'ascend'});
});
act(() => {
dom.window.dispatchEvent(new dom.window.PopStateEvent('popstate'));
});
assert.equal(results.state.selected_id, '');
assert.equal(results.state.page, 0);
/* sort_column は従来 sort_key へのタイポでリセットされていなかった */
assert.equal(results.state.sort_column, '');
assert.equal(results.state.sort_order, '');
m.unmount();
});

/*
リスナーを解除せずに unmount すると、後続の popstate が unmount 済みインスタンスの
onPopState を叩き、剥がされた ref (undefined) への setState で TypeError になっていた。
littel-ui のようにマウントし直す使い方で「Cannot read properties of undefined
(reading 'setState')」が出ていた原因。
*/
it('unmountするとpopstateに反応しなくなる', () => {
const m = mountIndex();
let called = 0;
m.instance.onPopState = () => { called++; };
dom.window.dispatchEvent(new dom.window.PopStateEvent('popstate'));
assert.equal(called, 1);
m.unmount();
dom.window.dispatchEvent(new dom.window.PopStateEvent('popstate'));
assert.equal(called, 1);
});

it('unmountするとscroll/resizeに反応しなくなる', () => {
const m = mountIndex();
let called = 0;
m.instance.onScroll = () => { called++; };
dom.window.dispatchEvent(new dom.window.Event('resize'));
assert.equal(called, 1);
m.unmount();
dom.window.dispatchEvent(new dom.window.Event('resize'));
assert.equal(called, 1);
});

it('unmountでwindow.pressKeyを片付ける', () => {
const m = mountIndex();
assert.equal(typeof dom.window.pressKey, 'function');
m.unmount();
assert.equal(dom.window.pressKey, undefined);
});
});

describe('Results(検索結果)', () => {
const base = {
filter: 0, filters: [{id: 0, name: '全域', includes: []}], excludes: [], selected_id: null,
Expand Down