From 1f673ab2c3f5e76bdc477516f7dcb43d0acaf42c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 20 Oct 2026 20:17:04 +0900 Subject: [PATCH] =?UTF-8?q?fix(498-1=20=EC=98=9B=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=92=80=EB=A6=BC):=20=EB=85=B8=EC=84=A0=20[?= =?UTF-8?q?=EC=A0=80=EC=9E=A5]=20=EB=92=A4=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=84=B8=EC=9A=B4=20=EC=98=9B=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EB=B6=99=EB=93=9C=EB=8A=94=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EB=93=A3=EA=B0=9C=20=EB=84=B7=20=C2=B7=20=EC=BD=94=EB=A6=AC?= =?UTF-8?q?=EB=8F=84=20=EC=BA=90=EC=8B=9C=20=E2=80=94=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20298-3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 공용 ui_template_window_watch(listenWhileAlive) — 창 · 문서 듣개는 종류마다 하나 · 화면 노드는 약하게 쥠 · 노드가 버려지면 저절로 빠짐 - design_leave beforeunload · overlay_drag pointermove · pointerup · pointercancel · resize · Panel_Settings document pointerup · keyup · side_panels scroll · resize 를 노드 수명으로 - side_panels 크기 감시가 place 를 약하게 쥠(감시가 노드를 안 붙듦) - 코리도 모듈 캐시 = 지금 노선 한 칸 - 시험 test_298_3_page_dispose(다섯 번 다시 세움 뒤 창 듣개 수 그대로 · 옛 노드 풀림) · helper_346_4 새 모듈 읽기 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_leave.ts | 6 +- B05_Profile/B05_Profile_UI_Corridor.ts | 10 +- B05_Profile/B05_Profile_UI_Panel_Settings.ts | 7 +- .../tester/helper_298_3_page_dispose.cjs | 134 ++++++++++++++++++ .../tester/helper_346_4_design_leave.cjs | 2 + resources/tester/test_298_3_page_dispose.py | 76 ++++++++++ ui_template/ui_template_overlay_drag.ts | 10 +- ui_template/ui_template_side_panels.ts | 26 ++-- ui_template/ui_template_window_watch.ts | 54 +++++++ 9 files changed, 300 insertions(+), 25 deletions(-) create mode 100644 resources/tester/helper_298_3_page_dispose.cjs create mode 100644 resources/tester/test_298_3_page_dispose.py create mode 100644 ui_template/ui_template_window_watch.ts diff --git a/A00_Common/design/design_leave.ts b/A00_Common/design/design_leave.ts index 3c9a534b8..3925c3fa5 100644 --- a/A00_Common/design/design_leave.ts +++ b/A00_Common/design/design_leave.ts @@ -14,6 +14,7 @@ import { ROUTES } from "@config/config_frontend"; import { leaveOrStay } from "@ui/ui_template_leave_dialog"; import { t as L } from "@ui/ui_template_locale"; +import { listenWhileAlive } from "@ui/ui_template_window_watch"; import { clearState, namesInBucket, @@ -99,12 +100,11 @@ export function guardDesignLeave( save: () => Promise, ): void { const unload = (event: BeforeUnloadEvent): void => { - if (!root.isConnected) return window.removeEventListener("beforeunload", unload); - if (!designDirty(projectId)) return; + if (!root.isConnected || !designDirty(projectId)) return; event.preventDefault(); event.returnValue = ""; }; - window.addEventListener("beforeunload", unload); + listenWhileAlive(window, "beforeunload", root, unload); // 다시 세운 옛 페이지를 안 붙듦(298-3) const guard = async (): Promise => { if (!root.isConnected) return true; if (SAME_PAGE.has(currentRoute())) { diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 38b31f5d8..cd1ef7aed 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -44,6 +44,12 @@ interface CacheEntry { const cache = new Map(); +/** 지금 노선 한 벌만 — 노선 [저장]마다 새 노선 번호라 옛 칸이 쌓임(298-3). */ +function remember(key: string, entry: CacheEntry): void { + cache.clear(); + cache.set(key, entry); +} + function keyOf(projectId: string, routeId: number): string { return `${projectId}:${routeId}`; } @@ -158,7 +164,7 @@ export async function ensureCorridor( if (lookup.kind === "envelope" && lookup.envelope.hash === hash) { try { const build = deserialize(lookup.envelope); - cache.set(key, { hash, build }); + remember(key, { hash, build }); markSource("stored", hash); return build; } catch { @@ -182,7 +188,7 @@ export async function ensureCorridor( } // 미리보기 — 메모리에만(올리지 않음 · 서버가 [저장]·[확정] 때 정본으로 지음) markSource("built", hash); - cache.set(key, { hash, build }); + remember(key, { hash, build }); return build; } diff --git a/B05_Profile/B05_Profile_UI_Panel_Settings.ts b/B05_Profile/B05_Profile_UI_Panel_Settings.ts index d66adf1f8..daf80e9aa 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Settings.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Settings.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { createSelectField } from "@ui/ui_template_elements"; +import { listenWhileAlive } from "@ui/ui_template_window_watch"; import { L, PROFILE_CRITERIA, @@ -137,13 +138,13 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) { const gradeSummary = document.createElement("summary"); gradeSummary.textContent = "기준값 직접 지정"; // 칸을 펼 때 · 펴 둔 동안 누름 · 키 뒤마다 종단 편집을 다시 비춤(끝점 ▲▼ · 되돌리기 · [저장] 뒤) - // ponytail: 문서 듣기는 페이지마다 하나 · 떼지 않음(가벼움) — 새는 게 보이면 페이지 수명에 묶을 것 + // 문서 듣기는 칸 수명 — 페이지를 다시 세워도 옛 칸을 붙들지 않음(298-3) gradeAdvanced.addEventListener("toggle", () => gradeAdvanced.open && showEndOffsets()); const refreshSoon = (): void => { if (gradeAdvanced.isConnected && gradeAdvanced.open) requestAnimationFrame(showEndOffsets); }; - document.addEventListener("pointerup", refreshSoon); - document.addEventListener("keyup", refreshSoon); + listenWhileAlive(document, "pointerup", gradeAdvanced, refreshSoon); + listenWhileAlive(document, "keyup", gradeAdvanced, refreshSoon); gradeAdvanced.append( gradeSummary, maxGradePct.wrapper, diff --git a/resources/tester/helper_298_3_page_dispose.cjs b/resources/tester/helper_298_3_page_dispose.cjs new file mode 100644 index 000000000..453f7c36f --- /dev/null +++ b/resources/tester/helper_298_3_page_dispose.cjs @@ -0,0 +1,134 @@ +/* 298-3 옛 페이지 풀림 단위검증 헬퍼 — `ui_template_window_watch` · `ui_template_overlay_drag` · + * `design_leave` 를 그 자리에서 트랜스파일 · 페이지를 다섯 번 다시 세운 뒤 창 듣개 수 · 옛 노드 풀림. + * `node --expose-gc` 로 부름 · 결과 JSON 을 stdout 으로. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +const session = new Map(); +const storage = { + getItem: (key) => (session.has(key) ? session.get(key) : null), + setItem: (key, value) => session.set(key, String(value)), + removeItem: (key) => session.delete(key), + key: (index) => [...session.keys()][index] ?? null, + get length() { + return session.size; + }, +}; +const win = new EventTarget(); +const doc = new EventTarget(); +const counts = { window: 0, document: 0 }; +for (const [name, target] of [ + ["window", win], + ["document", doc], +]) { + const add = target.addEventListener.bind(target); + target.addEventListener = (...args) => { + counts[name] += 1; + return add(...args); + }; +} +Object.assign(win, { + sessionStorage: storage, + localStorage: storage, + setTimeout, + innerWidth: 1500, + innerHeight: 900, +}); +globalThis.window = win; +globalThis.document = doc; +globalThis.sessionStorage = storage; +globalThis.localStorage = storage; +globalThis.requestAnimationFrame = (fn) => (fn(), 1); + +let guard = null; +const STUBS = { + "@config/config_frontend": { + ROUTES: { B05_PROFILE: "/b05-profile", B06_SECTION: "/b06-section" }, + }, + "@ui/ui_template_locale": { t: (key) => key }, + "@ui/ui_template_leave_dialog": { leaveOrStay: async () => "stay" }, + "../router": { currentRoute: () => "/b05-profile", setLeaveGuard: (fn) => (guard = fn) }, +}; + +const loaded = new Map(); +function loadTs(file) { + if (loaded.has(file)) return loaded.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const box = { exports: {} }; + loaded.set(file, box.exports); + const localRequire = (request) => { + if (request in STUBS) return STUBS[request]; + if (request.startsWith("@ui/")) + return loadTs(path.join(ROOT, "ui_template", `${request.slice(4)}.ts`)); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const drag = loadTs(path.join(ROOT, "ui_template", "ui_template_overlay_drag.ts")); +const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts")); + +let settles = 0; +function fakeElement() { + const node = new EventTarget(); + node.style = {}; + node.isConnected = true; + node.offsetWidth = 300; + node.offsetHeight = 40; + node.classList = { + contains: () => false, + add() {}, + toggle() {}, + remove: () => (settles += 1), // detach 가 부름 = 자리 다시 앉힘 + }; + node.querySelector = () => null; + node.getBoundingClientRect = () => ({ left: 10, top: 80 }); + return node; +} + +/** 페이지 한 벌 — 떠남 확인 + 진행단계 패널 · 옛 페이지 노드의 약한 손잡이를 돌려줌. */ +function mountPage() { + const page = fakeElement(); + const panel = fakeElement(); + page.panel = panel; // 페이지 나무 = 패널을 품음 + leave.guardDesignLeave("p1", page, async () => {}); + page.dragHandle = drag.makePanelDraggable(panel, fakeElement(), "pos"); + return { page: new WeakRef(page), panel: new WeakRef(panel), keep: page }; +} + +(async () => { + const out = {}; + let live = mountPage(); + out.afterFirst = { ...counts }; + const old = []; + for (let index = 0; index < 4; index += 1) { + live.keep.isConnected = false; // 다시 세움 — 옛 페이지는 화면에서 빠지고 버려짐 + old.push({ page: live.page, panel: live.panel }); + live = mountPage(); + } + out.afterFive = { ...counts }; + await new Promise((resolve) => setTimeout(resolve, 0)); + globalThis.gc(); + await new Promise((resolve) => setTimeout(resolve, 0)); + globalThis.gc(); + out.oldAlive = old.filter((pair) => pair.page.deref() || pair.panel.deref()).length; + out.liveAlive = Boolean(live.page.deref() && live.panel.deref()); + // 살아 있는 패널은 창 크기 바뀜을 여전히 들음 · 버려진 패널은 안 부름 + settles = 0; + win.dispatchEvent(new Event("resize")); + out.resizeSettles = settles; + // 떠남 확인 — 살아 있는 페이지만(고침 없음 = 안 막음) + const event = new Event("beforeunload", { cancelable: true }); + win.dispatchEvent(event); + out.unloadBlocked = event.defaultPrevented; + out.guardSet = typeof guard === "function"; + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_346_4_design_leave.cjs b/resources/tester/helper_346_4_design_leave.cjs index ebc38c799..57b410461 100644 --- a/resources/tester/helper_346_4_design_leave.cjs +++ b/resources/tester/helper_346_4_design_leave.cjs @@ -66,6 +66,8 @@ function loadTs(file) { const box = { exports: {} }; const localRequire = (request) => { if (request in STUBS) return STUBS[request]; + if (request === "@ui/ui_template_window_watch") + return loadTs(path.join(ROOT, "ui_template", "ui_template_window_watch.ts")); if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`)); return require(request); }; diff --git a/resources/tester/test_298_3_page_dispose.py b/resources/tester/test_298_3_page_dispose.py new file mode 100644 index 000000000..2664f51d8 --- /dev/null +++ b/resources/tester/test_298_3_page_dispose.py @@ -0,0 +1,76 @@ +"""298-3 노선 [저장] 뒤 옛 페이지 풀림 — 페이지를 다시 세워도 창 · 문서 듣개 수 그대로 · 코리도 캐시 한 칸. + +./venv/Scripts/python.exe -m pytest resources/tester/test_298_3_page_dispose.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).resolve().parent / "helper_298_3_page_dispose.cjs" + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", "--expose-gc", str(HELPER)], capture_output=True, text=True, encoding="utf-8" + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_다시_세워도_창_듣개_수_그대로(out) -> None: + assert ( + out["afterFirst"]["window"] == 5 + ) # beforeunload · pointermove · pointerup · pointercancel · resize + assert out["afterFive"] == out["afterFirst"] + + +def test_옛_페이지_풀림_지금_페이지는_들음(out) -> None: + assert out["oldAlive"] == 0 + assert out["liveAlive"] is True + assert out["resizeSettles"] == 1 # 살아 있는 패널 하나만 다시 앉힘 + assert out["unloadBlocked"] is False and out["guardSet"] is True + + +@pytest.mark.parametrize( + ("rel", "types"), + [ + ("A00_Common/design/design_leave.ts", "beforeunload"), + ("ui_template/ui_template_overlay_drag.ts", "pointermove|pointerup|pointercancel|resize"), + ( + "ui_template/ui_template_side_panels.ts", + "scroll|resize", + ), # 끌기 동안 pointer 듣기는 끝에 뗌 + ("B05_Profile/B05_Profile_UI_Panel_Settings.ts", "pointerup|keyup"), + ], +) +def test_전역_듣기는_노드_수명으로만(rel: str, types: str) -> None: + code = _read(rel) + assert not re.search(rf'\b(window|document)\.addEventListener\(\s*"({types})"', code) + assert "listenWhileAlive(" in code + + +def test_옆_패널_크기_감시는_노드를_안_붙듦() -> None: + side = _read("ui_template/ui_template_side_panels.ts") + assert "new ResizeObserver(() => ref.deref()?.())" in side + assert "const ref = new WeakRef(place);" in side + + +def test_코리도_캐시는_지금_노선_한_칸() -> None: + code = _read("B05_Profile/B05_Profile_UI_Corridor.ts") + assert ( + "function remember(key: string, entry: CacheEntry): void {\n cache.clear();\n cache.set(key, entry);\n}" + in code + ) + assert code.count("cache.set(") == 1 diff --git a/ui_template/ui_template_overlay_drag.ts b/ui_template/ui_template_overlay_drag.ts index 0ef110799..4cf399a88 100644 --- a/ui_template/ui_template_overlay_drag.ts +++ b/ui_template/ui_template_overlay_drag.ts @@ -19,6 +19,7 @@ */ import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import { listenWhileAlive } from "./ui_template_window_watch"; const DRAG_THRESHOLD_PX = 4; /** 상단 공용 헤더 높이(--spacing-64) — 그 위로는 못 올라간다. */ @@ -165,7 +166,8 @@ export function makePanelDraggable( // 포인터 이동은 **창 전체**에서 듣는다. 헤더에서만 들으면 커서가 패널 밖으로 // 나가는 순간 이동이 끊기고, 헤더에 포인터를 잡아 두면(setPointerCapture) 뒤따르는 // click 의 대상이 제목에서 헤더로 바뀌어 접기/펴기 토글이 죽는다(2026-09-04 실측). - window.addEventListener("pointermove", (event: PointerEvent) => { + // 창 듣기는 패널 수명 — 페이지를 다시 세워도 옛 패널을 붙들지 않는다(298-3). + listenWhileAlive(window, "pointermove", root, (event: PointerEvent) => { if (pointerId !== event.pointerId) return; const dx = event.clientX - startX; const dy = event.clientY - startY; @@ -197,15 +199,15 @@ export function makePanelDraggable( swallowNextClick(); } - window.addEventListener("pointerup", endDrag); - window.addEventListener("pointercancel", endDrag); + listenWhileAlive(window, "pointerup", root, endDrag); + listenWhileAlive(window, "pointercancel", root, endDrag); // 만들어진 직후에는 아직 DOM에 붙기 전이라 실측 크기가 0이다 — 한 프레임 뒤에 앉힌다. requestAnimationFrame(settle); // 창 폭이 바뀌면 다시 판정한다 — 좁아지면 물러나고 넓어지면 끌어 둔 자리를 되찾는다. let pending = 0; - window.addEventListener("resize", () => { + listenWhileAlive(window, "resize", root, () => { if (pending) return; pending = requestAnimationFrame(() => { pending = 0; diff --git a/ui_template/ui_template_side_panels.ts b/ui_template/ui_template_side_panels.ts index 187d3525f..539aeb30f 100644 --- a/ui_template/ui_template_side_panels.ts +++ b/ui_template/ui_template_side_panels.ts @@ -14,6 +14,7 @@ import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { el } from "./ui_template_elements"; import { currentLanguageIndex } from "./ui_template_locale"; import { createWorkflowPanelHandle } from "./ui_template_overlay"; +import { listenWhileAlive } from "./ui_template_window_watch"; import "./ui_template_side_panels.css"; const TEXT = { @@ -191,14 +192,11 @@ function scroller(node: HTMLElement): HTMLElement | null { * 패널 묶음을 보이는 칸 안에 붙임 — 높이 = 보이는 창 높이 − 머리띠(칸이 아래에 있으면 칸 위부터) · 본문을 굴려도 그 자리(몸만 따로 스크롤) * CSS sticky 는 위 칸의 overflow 가 막아 JS 로 — 어느 칸이 굴러도(capture) 다시 잼 */ -function followView(node: HTMLElement): void { - const ref = new WeakRef(node); // 떼었다 다시 붙이는 화면(M01)도 있어 떼어져도 듣기는 남김 · 버려지면 그침 +function followView(root: HTMLElement): void { + // 떼었다 다시 붙이는 화면(M01)도 있어 떼어져도 듣기는 남김 · 창 듣기 = 노드 수명(버려지면 그침 · 298-3) // 다시 붙음 · 칸 내용이 늦게 차 칸 크기가 바뀜(스크롤 없이) → 다시 잼 - const sized = new ResizeObserver(() => place()); let watched: Element | null = null; const place = (): void => { - const root = ref.deref(); - if (!root) return stop(); if (!root.isConnected) return; if (root.parentElement && root.parentElement !== watched) { if (watched) sized.unobserve(watched); @@ -219,13 +217,15 @@ function followView(node: HTMLElement): void { const shift = Math.min(Math.max(0, view - row.top), Math.max(0, row.height + 48 - height)); root.style.transform = shift ? `translateY(${Math.round(shift)}px)` : ""; }; - const stop = (): void => { - window.removeEventListener("scroll", place, true); - window.removeEventListener("resize", place); - sized.disconnect(); - }; - window.addEventListener("scroll", place, true); - window.addEventListener("resize", place); - sized.observe(node); + const sized = sizeWatch(place); + listenWhileAlive(window, "scroll", root, place, true); + listenWhileAlive(window, "resize", root, place); + sized.observe(root); setTimeout(place); // 붙은 뒤 한 번 } + +/** 크기 감시는 `place` 를 약하게 쥠 — 감시가 노드를 붙들지 않게(따로 함수: 닫힘이 `root` 를 안 쥠). */ +function sizeWatch(place: () => void): ResizeObserver { + const ref = new WeakRef(place); + return new ResizeObserver(() => ref.deref()?.()); +} diff --git a/ui_template/ui_template_window_watch.ts b/ui_template/ui_template_window_watch.ts new file mode 100644 index 000000000..a513518f8 --- /dev/null +++ b/ui_template/ui_template_window_watch.ts @@ -0,0 +1,54 @@ +/* ============================================================================= + * ui_template/ui_template_window_watch.ts + * 창 · 문서 듣기를 화면 노드 수명에 묶음(298-3) — 종류마다 듣개 하나만 걸고 노드는 약하게 쥠. + * 페이지를 다시 세워도(노선 [저장]) 옛 페이지를 붙들지 않고 창 듣개 수가 늘지 않음. + * 노드가 살아 있는 동안은 떼었다 다시 붙여도(M01) 계속 들음 · 버려지면 저절로 빠짐. + * ========================================================================== */ + +type Call = (event: Event) => void; + +interface Hub { + nodes: Set>; + /** 노드 → 부를 것 — 값이 노드를 쥐어도 노드와 함께 풀림(WeakMap). */ + calls: WeakMap; +} + +const hubs = new WeakMap>(); + +function hubOf(target: EventTarget, type: string, capture: boolean): Hub { + let byType = hubs.get(target); + if (!byType) hubs.set(target, (byType = new Map())); + const key = `${type}:${capture}`; + const found = byType.get(key); + if (found) return found; + const hub: Hub = { nodes: new Set(), calls: new WeakMap() }; + target.addEventListener( + type, + (event) => + hub.nodes.forEach((ref) => { + const node = ref.deref(); + if (!node) hub.nodes.delete(ref); + else hub.calls.get(node)?.forEach((call) => call(event)); + }), + capture, + ); + byType.set(key, hub); + return hub; +} + +/** `target` 의 `type` 을 `node` 가 살아 있는 동안 들음. */ +export function listenWhileAlive( + target: EventTarget, + type: string, + node: object, + call: (event: E) => void, + capture = false, +): void { + const hub = hubOf(target, type, capture); + const calls = hub.calls.get(node); + if (calls) calls.push(call as Call); + else { + hub.calls.set(node, [call as Call]); + hub.nodes.add(new WeakRef(node)); + } +}