Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
b5863d76
Unverified
Commit
b5863d76
authored
Apr 20, 2026
by
boojack
Committed by
GitHub
Apr 20, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(web): preserve task checkbox state (#5867)
parent
d98f6659
Changes
9
Show whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
235 additions
and
29 deletions
+235
-29
TaskListItem.tsx
web/src/components/MemoContent/TaskListItem.tsx
+1
-1
constants.ts
web/src/components/MemoContent/constants.ts
+2
-0
useAutoSave.ts
web/src/components/MemoEditor/hooks/useAutoSave.ts
+17
-1
useMemoInit.ts
web/src/components/MemoEditor/hooks/useMemoInit.ts
+2
-8
index.tsx
web/src/components/MemoEditor/index.tsx
+6
-4
cacheService.ts
web/src/components/MemoEditor/services/cacheService.ts
+31
-11
useMemoQueries.ts
web/src/hooks/useMemoQueries.ts
+102
-4
memo-content-security.test.tsx
web/tests/memo-content-security.test.tsx
+17
-0
memo-editor-cache.test.ts
web/tests/memo-editor-cache.test.ts
+57
-0
No files found.
web/src/components/MemoContent/TaskListItem.tsx
View file @
b5863d76
...
@@ -61,7 +61,7 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, node: _node
...
@@ -61,7 +61,7 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, node: _node
name
:
memo
.
name
,
name
:
memo
.
name
,
content
:
newContent
,
content
:
newContent
,
},
},
updateMask
:
[
"content"
],
updateMask
:
[
"content"
,
"update_time"
],
});
});
};
};
...
...
web/src/components/MemoContent/constants.ts
View file @
b5863d76
...
@@ -32,6 +32,7 @@ const TRUSTED_IFRAME_SRC_PATTERNS = [
...
@@ -32,6 +32,7 @@ const TRUSTED_IFRAME_SRC_PATTERNS = [
const
KATEX_INLINE_CLASS_NAMES
=
[
"language-math"
,
"math-inline"
]
as
const
;
const
KATEX_INLINE_CLASS_NAMES
=
[
"language-math"
,
"math-inline"
]
as
const
;
const
KATEX_BLOCK_CLASS_NAMES
=
[
"language-math"
,
"math-display"
]
as
const
;
const
KATEX_BLOCK_CLASS_NAMES
=
[
"language-math"
,
"math-display"
]
as
const
;
const
SPAN_CLASS_NAMES
=
[
"mention"
,
"tag"
]
as
const
;
const
SPAN_CLASS_NAMES
=
[
"mention"
,
"tag"
]
as
const
;
const
INPUT_ATTRIBUTES
=
[...(
defaultSchema
.
attributes
?.
input
||
[]),
[
"checked"
,
true
]]
as
const
;
export
const
isTrustedIframeSrc
=
(
src
:
string
):
boolean
=>
TRUSTED_IFRAME_SRC_PATTERNS
.
some
((
pattern
)
=>
pattern
.
test
(
src
));
export
const
isTrustedIframeSrc
=
(
src
:
string
):
boolean
=>
TRUSTED_IFRAME_SRC_PATTERNS
.
some
((
pattern
)
=>
pattern
.
test
(
src
));
...
@@ -49,6 +50,7 @@ export const SANITIZE_SCHEMA = {
...
@@ -49,6 +50,7 @@ export const SANITIZE_SCHEMA = {
attributes
:
{
attributes
:
{
...
defaultSchema
.
attributes
,
...
defaultSchema
.
attributes
,
img
:
[...(
defaultSchema
.
attributes
?.
img
||
[]),
"height"
,
"width"
],
img
:
[...(
defaultSchema
.
attributes
?.
img
||
[]),
"height"
,
"width"
],
input
:
INPUT_ATTRIBUTES
,
code
:
[...(
defaultSchema
.
attributes
?.
code
||
[]),
[
"className"
,
...
KATEX_INLINE_CLASS_NAMES
,
...
KATEX_BLOCK_CLASS_NAMES
]],
code
:
[...(
defaultSchema
.
attributes
?.
code
||
[]),
[
"className"
,
...
KATEX_INLINE_CLASS_NAMES
,
...
KATEX_BLOCK_CLASS_NAMES
]],
span
:
[...(
defaultSchema
.
attributes
?.
span
||
[]),
[
"className"
,
...
SPAN_CLASS_NAMES
],
[
"aria*"
],
[
"data*"
]],
span
:
[...(
defaultSchema
.
attributes
?.
span
||
[]),
[
"className"
,
...
SPAN_CLASS_NAMES
],
[
"aria*"
],
[
"data*"
]],
iframe
:
[
iframe
:
[
...
...
web/src/components/MemoEditor/hooks/useAutoSave.ts
View file @
b5863d76
import
{
useEffect
,
useRef
}
from
"react"
;
import
{
use
Callback
,
use
Effect
,
useRef
}
from
"react"
;
import
{
cacheService
}
from
"../services"
;
import
{
cacheService
}
from
"../services"
;
export
const
useAutoSave
=
(
content
:
string
,
username
:
string
,
cacheKey
:
string
|
undefined
,
enabled
=
true
)
=>
{
export
const
useAutoSave
=
(
content
:
string
,
username
:
string
,
cacheKey
:
string
|
undefined
,
enabled
=
true
)
=>
{
const
latestContentRef
=
useRef
(
content
);
const
latestContentRef
=
useRef
(
content
);
const
discardedContentRef
=
useRef
<
string
|
undefined
>
(
undefined
);
useEffect
(()
=>
{
useEffect
(()
=>
{
latestContentRef
.
current
=
content
;
latestContentRef
.
current
=
content
;
if
(
discardedContentRef
.
current
!==
undefined
&&
discardedContentRef
.
current
!==
content
)
{
discardedContentRef
.
current
=
undefined
;
}
},
[
content
]);
},
[
content
]);
useEffect
(()
=>
{
useEffect
(()
=>
{
...
@@ -20,6 +24,10 @@ export const useAutoSave = (content: string, username: string, cacheKey: string
...
@@ -20,6 +24,10 @@ export const useAutoSave = (content: string, username: string, cacheKey: string
const
key
=
cacheService
.
key
(
username
,
cacheKey
);
const
key
=
cacheService
.
key
(
username
,
cacheKey
);
const
flushDraft
=
()
=>
{
const
flushDraft
=
()
=>
{
if
(
discardedContentRef
.
current
===
latestContentRef
.
current
)
{
return
;
}
cacheService
.
saveNow
(
key
,
latestContentRef
.
current
);
cacheService
.
saveNow
(
key
,
latestContentRef
.
current
);
};
};
const
handleVisibilityChange
=
()
=>
{
const
handleVisibilityChange
=
()
=>
{
...
@@ -39,4 +47,12 @@ export const useAutoSave = (content: string, username: string, cacheKey: string
...
@@ -39,4 +47,12 @@ export const useAutoSave = (content: string, username: string, cacheKey: string
document
.
removeEventListener
(
"visibilitychange"
,
handleVisibilityChange
);
document
.
removeEventListener
(
"visibilitychange"
,
handleVisibilityChange
);
};
};
},
[
username
,
cacheKey
,
enabled
]);
},
[
username
,
cacheKey
,
enabled
]);
const
discardDraft
=
useCallback
(()
=>
{
const
key
=
cacheService
.
key
(
username
,
cacheKey
);
discardedContentRef
.
current
=
latestContentRef
.
current
;
cacheService
.
clear
(
key
);
},
[
username
,
cacheKey
]);
return
{
discardDraft
};
};
};
web/src/components/MemoEditor/hooks/useMemoInit.ts
View file @
b5863d76
...
@@ -22,19 +22,13 @@ export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, de
...
@@ -22,19 +22,13 @@ export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, de
if
(
initializedRef
.
current
)
return
;
if
(
initializedRef
.
current
)
return
;
initializedRef
.
current
=
true
;
initializedRef
.
current
=
true
;
const
key
=
cacheService
.
key
(
username
,
cacheKey
);
const
key
=
cacheService
.
key
(
username
,
cacheKey
);
const
cachedContent
=
cacheService
.
load
(
key
);
if
(
memo
)
{
if
(
memo
)
{
const
initialState
=
memoService
.
fromMemo
(
memo
);
const
initialState
=
memoService
.
fromMemo
(
memo
);
// Prefer cached draft over the saved memo content when the user had unsaved
cacheService
.
clear
(
key
);
// changes (e.g. tab was suspended mid-edit). Uses strict string comparison
// against memo.content — both values come from the same proto serialization
// path, so format is consistent and whitespace differences are intentional.
if
(
cachedContent
.
trim
()
&&
cachedContent
!==
memo
.
content
)
{
initialState
.
content
=
cachedContent
;
}
dispatch
(
actions
.
initMemo
(
initialState
));
dispatch
(
actions
.
initMemo
(
initialState
));
}
else
{
}
else
{
const
cachedContent
=
cacheService
.
load
(
key
);
if
(
cachedContent
)
{
if
(
cachedContent
)
{
dispatch
(
actions
.
updateContent
(
cachedContent
));
dispatch
(
actions
.
updateContent
(
cachedContent
));
}
}
...
...
web/src/components/MemoEditor/index.tsx
View file @
b5863d76
...
@@ -23,7 +23,7 @@ import {
...
@@ -23,7 +23,7 @@ import {
import
{
FOCUS_MODE_STYLES
}
from
"./constants"
;
import
{
FOCUS_MODE_STYLES
}
from
"./constants"
;
import
type
{
EditorRefActions
}
from
"./Editor"
;
import
type
{
EditorRefActions
}
from
"./Editor"
;
import
{
useAudioRecorder
,
useAutoSave
,
useFocusMode
,
useKeyboard
,
useMemoInit
}
from
"./hooks"
;
import
{
useAudioRecorder
,
useAutoSave
,
useFocusMode
,
useKeyboard
,
useMemoInit
}
from
"./hooks"
;
import
{
cacheService
,
errorService
,
memoService
,
transcriptionService
,
validationService
}
from
"./services"
;
import
{
errorService
,
memoService
,
transcriptionService
,
validationService
}
from
"./services"
;
import
{
EditorProvider
,
useEditorContext
}
from
"./state"
;
import
{
EditorProvider
,
useEditorContext
}
from
"./state"
;
import
type
{
MemoEditorProps
}
from
"./types"
;
import
type
{
MemoEditorProps
}
from
"./types"
;
import
type
{
LocalFile
}
from
"./types/attachment"
;
import
type
{
LocalFile
}
from
"./types/attachment"
;
...
@@ -69,9 +69,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
...
@@ -69,9 +69,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
const
defaultVisibility
=
userGeneralSetting
?.
memoVisibility
?
convertVisibilityFromString
(
userGeneralSetting
.
memoVisibility
)
:
undefined
;
const
defaultVisibility
=
userGeneralSetting
?.
memoVisibility
?
convertVisibilityFromString
(
userGeneralSetting
.
memoVisibility
)
:
undefined
;
const
{
isInitialized
}
=
useMemoInit
({
editorRef
,
memo
,
cacheKey
,
username
:
currentUser
?.
name
??
""
,
autoFocus
,
defaultVisibility
});
const
{
isInitialized
}
=
useMemoInit
({
editorRef
,
memo
,
cacheKey
,
username
:
currentUser
?.
name
??
""
,
autoFocus
,
defaultVisibility
});
const
isDraftCacheEnabled
=
!
memo
;
// Auto-save content to localStorage
// Auto-save content to localStorage
useAutoSave
(
state
.
content
,
currentUser
?.
name
??
""
,
cacheKey
,
isInitializ
ed
);
const
{
discardDraft
}
=
useAutoSave
(
state
.
content
,
currentUser
?.
name
??
""
,
cacheKey
,
isInitialized
&&
isDraftCacheEnabl
ed
);
// Focus mode management with body scroll lock
// Focus mode management with body scroll lock
useFocusMode
(
state
.
ui
.
isFocusMode
);
useFocusMode
(
state
.
ui
.
isFocusMode
);
...
@@ -229,8 +230,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
...
@@ -229,8 +230,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
return
;
return
;
}
}
// Clear localStorage cache on successful save
// Clear localStorage cache on successful save and prevent the unmount
cacheService
.
clear
(
cacheService
.
key
(
currentUser
?.
name
??
""
,
cacheKey
));
// flush from writing the just-saved content back as a stale draft.
discardDraft
();
// Invalidate React Query cache to refresh memo lists across the app
// Invalidate React Query cache to refresh memo lists across the app
const
invalidationPromises
=
[
const
invalidationPromises
=
[
...
...
web/src/components/MemoEditor/services/cacheService.ts
View file @
b5863d76
export
const
CACHE_DEBOUNCE_DELAY
=
500
;
export
const
CACHE_DEBOUNCE_DELAY
=
500
;
const
pendingSaves
=
new
Map
<
string
,
ReturnType
<
typeof
window
.
setTimeout
>>
();
const
pendingSaves
=
new
Map
<
string
,
ReturnType
<
typeof
window
.
setTimeout
>>
();
const
STRUCTURED_CACHE_ENTRY_KIND
=
"memos.editor-cache"
;
const
STRUCTURED_CACHE_ENTRY_VERSION
=
1
;
function
deserializeContent
(
raw
:
string
):
string
{
try
{
const
parsed
=
JSON
.
parse
(
raw
)
as
{
kind
?:
unknown
;
version
?:
unknown
;
content
?:
unknown
};
if
(
parsed
.
kind
===
STRUCTURED_CACHE_ENTRY_KIND
&&
parsed
.
version
===
STRUCTURED_CACHE_ENTRY_VERSION
&&
typeof
parsed
.
content
===
"string"
)
{
return
parsed
.
content
;
}
}
catch
{
// Drafts have historically been stored as raw markdown strings.
}
return
raw
;
}
function
writeEntry
(
key
:
string
,
content
:
string
):
void
{
if
(
content
.
trim
())
{
localStorage
.
setItem
(
key
,
content
);
}
else
{
localStorage
.
removeItem
(
key
);
}
}
export
const
cacheService
=
{
export
const
cacheService
=
{
key
:
(
username
:
string
,
cacheKey
?:
string
):
string
=>
{
key
:
(
username
:
string
,
cacheKey
?:
string
):
string
=>
{
...
@@ -16,11 +43,7 @@ export const cacheService = {
...
@@ -16,11 +43,7 @@ export const cacheService = {
const
timeoutId
=
window
.
setTimeout
(()
=>
{
const
timeoutId
=
window
.
setTimeout
(()
=>
{
pendingSaves
.
delete
(
key
);
pendingSaves
.
delete
(
key
);
if
(
content
.
trim
())
{
writeEntry
(
key
,
content
);
localStorage
.
setItem
(
key
,
content
);
}
else
{
localStorage
.
removeItem
(
key
);
}
},
CACHE_DEBOUNCE_DELAY
);
},
CACHE_DEBOUNCE_DELAY
);
pendingSaves
.
set
(
key
,
timeoutId
);
pendingSaves
.
set
(
key
,
timeoutId
);
...
@@ -33,15 +56,12 @@ export const cacheService = {
...
@@ -33,15 +56,12 @@ export const cacheService = {
pendingSaves
.
delete
(
key
);
pendingSaves
.
delete
(
key
);
}
}
if
(
content
.
trim
())
{
writeEntry
(
key
,
content
);
localStorage
.
setItem
(
key
,
content
);
}
else
{
localStorage
.
removeItem
(
key
);
}
},
},
load
(
key
:
string
):
string
{
load
(
key
:
string
):
string
{
return
localStorage
.
getItem
(
key
)
||
""
;
const
raw
=
localStorage
.
getItem
(
key
);
return
raw
?
deserializeContent
(
raw
)
:
""
;
},
},
clear
(
key
:
string
):
void
{
clear
(
key
:
string
):
void
{
...
...
web/src/hooks/useMemoQueries.ts
View file @
b5863d76
import
{
create
}
from
"@bufbuild/protobuf"
;
import
{
create
}
from
"@bufbuild/protobuf"
;
import
{
FieldMaskSchema
}
from
"@bufbuild/protobuf/wkt"
;
import
{
FieldMaskSchema
}
from
"@bufbuild/protobuf/wkt"
;
import
type
{
InfiniteData
}
from
"@tanstack/react-query"
;
import
{
useInfiniteQuery
,
useMutation
,
useQuery
,
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
useInfiniteQuery
,
useMutation
,
useQuery
,
useQueryClient
}
from
"@tanstack/react-query"
;
import
{
memoServiceClient
}
from
"@/connect"
;
import
{
memoServiceClient
}
from
"@/connect"
;
import
{
userKeys
}
from
"@/hooks/useUserQueries"
;
import
{
userKeys
}
from
"@/hooks/useUserQueries"
;
import
type
{
ListMemosRequest
,
Memo
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
type
{
ListMemosRequest
,
ListMemosResponse
,
Memo
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
ListMemosRequestSchema
,
MemoSchema
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
ListMemosRequestSchema
,
MemoSchema
}
from
"@/types/proto/api/v1/memo_service_pb"
;
// Query keys factory for consistent cache management
// Query keys factory for consistent cache management
...
@@ -16,6 +17,96 @@ export const memoKeys = {
...
@@ -16,6 +17,96 @@ export const memoKeys = {
comments
:
(
name
:
string
)
=>
[...
memoKeys
.
all
,
"comments"
,
name
]
as
const
,
comments
:
(
name
:
string
)
=>
[...
memoKeys
.
all
,
"comments"
,
name
]
as
const
,
};
};
type
MemoPatch
=
Partial
<
Memo
>
&
Pick
<
Memo
,
"name"
>
;
type
MemoCollectionQueryData
=
ListMemosResponse
|
InfiniteData
<
ListMemosResponse
>
;
function
isMemoListResponse
(
data
:
unknown
):
data
is
ListMemosResponse
{
return
typeof
data
===
"object"
&&
data
!==
null
&&
Array
.
isArray
((
data
as
{
memos
?:
unknown
}).
memos
);
}
function
isInfiniteMemoListData
(
data
:
unknown
):
data
is
InfiniteData
<
ListMemosResponse
>
{
return
typeof
data
===
"object"
&&
data
!==
null
&&
Array
.
isArray
((
data
as
{
pages
?:
unknown
}).
pages
);
}
function
patchMemoListResponse
(
response
:
ListMemosResponse
,
update
:
MemoPatch
):
ListMemosResponse
{
let
changed
=
false
;
const
memos
=
response
.
memos
.
map
((
memo
)
=>
{
if
(
memo
.
name
!==
update
.
name
)
{
return
memo
;
}
changed
=
true
;
return
{
...
memo
,
...
update
};
});
return
changed
?
{
...
response
,
memos
}
:
response
;
}
function
patchMemoListQueryData
<
T
>
(
data
:
T
|
undefined
,
update
:
MemoPatch
):
T
|
undefined
{
if
(
!
data
)
{
return
data
;
}
if
(
isMemoListResponse
(
data
))
{
return
patchMemoListResponse
(
data
,
update
)
as
T
;
}
if
(
isInfiniteMemoListData
(
data
))
{
let
changed
=
false
;
const
pages
=
data
.
pages
.
map
((
page
)
=>
{
const
patchedPage
=
patchMemoListResponse
(
page
,
update
);
if
(
patchedPage
!==
page
)
{
changed
=
true
;
}
return
patchedPage
;
});
return
(
changed
?
{
...
data
,
pages
}
:
data
)
as
T
;
}
return
data
;
}
function
findMemoInListResponse
(
response
:
ListMemosResponse
,
name
:
string
):
Memo
|
undefined
{
return
response
.
memos
.
find
((
memo
)
=>
memo
.
name
===
name
);
}
function
findMemoInQueryData
(
data
:
unknown
,
name
:
string
):
Memo
|
undefined
{
if
(
!
data
)
{
return
undefined
;
}
if
(
isMemoListResponse
(
data
))
{
return
findMemoInListResponse
(
data
,
name
);
}
if
(
isInfiniteMemoListData
(
data
))
{
for
(
const
page
of
data
.
pages
)
{
const
memo
=
findMemoInListResponse
(
page
,
name
);
if
(
memo
)
{
return
memo
;
}
}
}
return
undefined
;
}
function
findMemoInCollectionQueries
(
queryClient
:
ReturnType
<
typeof
useQueryClient
>
,
name
:
string
):
Memo
|
undefined
{
for
(
const
[,
data
]
of
queryClient
.
getQueriesData
<
unknown
>
({
queryKey
:
memoKeys
.
all
}))
{
const
memo
=
findMemoInQueryData
(
data
,
name
);
if
(
memo
)
{
return
memo
;
}
}
return
undefined
;
}
function
patchMemoInCollectionQueries
(
queryClient
:
ReturnType
<
typeof
useQueryClient
>
,
update
:
MemoPatch
)
{
queryClient
.
setQueriesData
<
MemoCollectionQueryData
>
({
queryKey
:
memoKeys
.
all
},
(
data
)
=>
patchMemoListQueryData
(
data
,
update
));
}
export
function
useMemos
(
request
:
Partial
<
ListMemosRequest
>
=
{})
{
export
function
useMemos
(
request
:
Partial
<
ListMemosRequest
>
=
{})
{
return
useQuery
({
return
useQuery
({
queryKey
:
memoKeys
.
list
(
request
),
queryKey
:
memoKeys
.
list
(
request
),
...
@@ -94,15 +185,18 @@ export function useUpdateMemo() {
...
@@ -94,15 +185,18 @@ export function useUpdateMemo() {
}
}
// Cancel outgoing refetches to prevent race conditions
// Cancel outgoing refetches to prevent race conditions
await
queryClient
.
cancelQueries
({
queryKey
:
memoKeys
.
detail
(
update
.
name
)
});
await
queryClient
.
cancelQueries
({
queryKey
:
memoKeys
.
all
});
// Snapshot previous value for rollback on error
// Snapshot previous value for rollback on error
const
previousMemo
=
queryClient
.
getQueryData
<
Memo
>
(
memoKeys
.
detail
(
update
.
name
));
const
previousMemo
=
queryClient
.
getQueryData
<
Memo
>
(
memoKeys
.
detail
(
update
.
name
))
||
findMemoInCollectionQueries
(
queryClient
,
update
.
name
);
const
memoPatch
:
MemoPatch
=
{
...
update
,
name
:
update
.
name
};
// Optimistically update the cache
// Optimistically update the cache
if
(
previousMemo
)
{
if
(
previousMemo
)
{
queryClient
.
setQueryData
(
memoKeys
.
detail
(
update
.
name
),
{
...
previousMemo
,
...
update
});
queryClient
.
setQueryData
(
memoKeys
.
detail
(
update
.
name
),
{
...
previousMemo
,
...
memoPatch
});
}
}
patchMemoInCollectionQueries
(
queryClient
,
memoPatch
);
return
{
previousMemo
};
return
{
previousMemo
};
},
},
...
@@ -110,11 +204,15 @@ export function useUpdateMemo() {
...
@@ -110,11 +204,15 @@ export function useUpdateMemo() {
// Rollback on error
// Rollback on error
if
(
context
?.
previousMemo
&&
update
.
name
)
{
if
(
context
?.
previousMemo
&&
update
.
name
)
{
queryClient
.
setQueryData
(
memoKeys
.
detail
(
update
.
name
),
context
.
previousMemo
);
queryClient
.
setQueryData
(
memoKeys
.
detail
(
update
.
name
),
context
.
previousMemo
);
patchMemoInCollectionQueries
(
queryClient
,
context
.
previousMemo
);
}
else
{
queryClient
.
invalidateQueries
({
queryKey
:
memoKeys
.
all
});
}
}
},
},
onSuccess
:
(
updatedMemo
)
=>
{
onSuccess
:
(
updatedMemo
)
=>
{
// Update cache with server response
// Update cache with server response
queryClient
.
setQueryData
(
memoKeys
.
detail
(
updatedMemo
.
name
),
updatedMemo
);
queryClient
.
setQueryData
(
memoKeys
.
detail
(
updatedMemo
.
name
),
updatedMemo
);
patchMemoInCollectionQueries
(
queryClient
,
updatedMemo
);
// Invalidate lists to refresh
// Invalidate lists to refresh
queryClient
.
invalidateQueries
({
queryKey
:
memoKeys
.
lists
()
});
queryClient
.
invalidateQueries
({
queryKey
:
memoKeys
.
lists
()
});
if
(
updatedMemo
.
parent
)
{
if
(
updatedMemo
.
parent
)
{
...
...
web/tests/memo-content-security.test.tsx
View file @
b5863d76
...
@@ -4,6 +4,7 @@ import ReactMarkdown from "react-markdown";
...
@@ -4,6 +4,7 @@ import ReactMarkdown from "react-markdown";
import
rehypeKatex
from
"rehype-katex"
;
import
rehypeKatex
from
"rehype-katex"
;
import
rehypeRaw
from
"rehype-raw"
;
import
rehypeRaw
from
"rehype-raw"
;
import
rehypeSanitize
from
"rehype-sanitize"
;
import
rehypeSanitize
from
"rehype-sanitize"
;
import
remarkGfm
from
"remark-gfm"
;
import
remarkMath
from
"remark-math"
;
import
remarkMath
from
"remark-math"
;
import
{
describe
,
expect
,
it
}
from
"vitest"
;
import
{
describe
,
expect
,
it
}
from
"vitest"
;
import
{
SANITIZE_SCHEMA
,
isTrustedIframeSrc
}
from
"@/components/MemoContent/constants"
;
import
{
SANITIZE_SCHEMA
,
isTrustedIframeSrc
}
from
"@/components/MemoContent/constants"
;
...
@@ -28,6 +29,13 @@ const renderMemoContent = (content: string): string =>
...
@@ -28,6 +29,13 @@ const renderMemoContent = (content: string): string =>
</
ReactMarkdown
>,
</
ReactMarkdown
>,
);
);
const
renderGfmContent
=
(
content
:
string
):
string
=>
renderToStaticMarkup
(
<
ReactMarkdown
remarkPlugins=
{
[
remarkGfm
]
}
rehypePlugins=
{
[[
rehypeSanitize
,
SANITIZE_SCHEMA
]]
}
>
{
content
}
</
ReactMarkdown
>,
);
describe
(
"memo content sanitization"
,
()
=>
{
describe
(
"memo content sanitization"
,
()
=>
{
it
(
"strips user-controlled inline styles from raw HTML spans"
,
()
=>
{
it
(
"strips user-controlled inline styles from raw HTML spans"
,
()
=>
{
const
html
=
renderMemoContent
(
'<span style="position:fixed;inset:0;z-index:99999">overlay</span>'
);
const
html
=
renderMemoContent
(
'<span style="position:fixed;inset:0;z-index:99999">overlay</span>'
);
...
@@ -43,6 +51,15 @@ describe("memo content sanitization", () => {
...
@@ -43,6 +51,15 @@ describe("memo content sanitization", () => {
expect
(
html
).
toMatch
(
/class="katex"/
);
expect
(
html
).
toMatch
(
/class="katex"/
);
expect
(
html
).
toMatch
(
/class="katex-html"/
);
expect
(
html
).
toMatch
(
/class="katex-html"/
);
});
});
it
(
"preserves checked state for GFM task list items"
,
()
=>
{
const
html
=
renderGfmContent
(
"- [x] Done
\n
- [ ] Todo"
);
const
inputs
=
html
.
match
(
/<input
[^
>
]
+
\/
>/g
)
??
[];
expect
(
inputs
).
toHaveLength
(
2
);
expect
(
inputs
[
0
]).
toContain
(
'checked=""'
);
expect
(
inputs
[
1
]).
not
.
toContain
(
'checked=""'
);
});
});
});
describe
(
"trusted iframe providers"
,
()
=>
{
describe
(
"trusted iframe providers"
,
()
=>
{
...
...
web/tests/memo-editor-cache.test.ts
0 → 100644
View file @
b5863d76
import
{
afterEach
,
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
"vitest"
;
import
{
cacheService
}
from
"@/components/MemoEditor/services/cacheService"
;
describe
(
"memo editor cache"
,
()
=>
{
beforeEach
(()
=>
{
const
storage
=
new
Map
<
string
,
string
>
();
vi
.
stubGlobal
(
"localStorage"
,
{
getItem
:
vi
.
fn
((
key
:
string
)
=>
storage
.
get
(
key
)
??
null
),
setItem
:
vi
.
fn
((
key
:
string
,
value
:
string
)
=>
storage
.
set
(
key
,
value
)),
removeItem
:
vi
.
fn
((
key
:
string
)
=>
storage
.
delete
(
key
)),
});
cacheService
.
clearAll
();
});
afterEach
(()
=>
{
vi
.
unstubAllGlobals
();
});
it
(
"stores draft content"
,
()
=>
{
const
key
=
cacheService
.
key
(
"users/steven"
,
"home-memo-editor"
);
cacheService
.
saveNow
(
key
,
"- [x] Draft task"
);
expect
(
cacheService
.
load
(
key
)).
toBe
(
"- [x] Draft task"
);
});
it
(
"removes empty draft content instead of caching it"
,
()
=>
{
const
key
=
cacheService
.
key
(
"users/steven"
,
"home-memo-editor"
);
cacheService
.
saveNow
(
key
,
""
);
expect
(
cacheService
.
load
(
key
)).
toBe
(
""
);
});
it
(
"loads content from previously structured draft entries"
,
()
=>
{
const
key
=
cacheService
.
key
(
"users/steven"
,
"home-memo-editor"
);
localStorage
.
setItem
(
key
,
JSON
.
stringify
({
kind
:
"memos.editor-cache"
,
version
:
1
,
content
:
"- [ ] migrated task"
}));
expect
(
cacheService
.
load
(
key
)).
toBe
(
"- [ ] migrated task"
);
});
it
(
"keeps raw JSON markdown drafts intact"
,
()
=>
{
const
key
=
cacheService
.
key
(
"users/steven"
,
"home-memo-editor"
);
const
jsonDraft
=
'{"content":"not a cache envelope"}'
;
localStorage
.
setItem
(
key
,
jsonDraft
);
expect
(
cacheService
.
load
(
key
)).
toBe
(
jsonDraft
);
});
it
(
"keeps structured-looking drafts without a supported version intact"
,
()
=>
{
const
key
=
cacheService
.
key
(
"users/steven"
,
"home-memo-editor"
);
const
jsonDraft
=
JSON
.
stringify
({
kind
:
"memos.editor-cache"
,
content
:
"not a supported envelope"
});
localStorage
.
setItem
(
key
,
jsonDraft
);
expect
(
cacheService
.
load
(
key
)).
toBe
(
jsonDraft
);
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment