Kontext
Folio::Tiptap.config (app/lib/folio/tiptap.rb:15) je memoizovaný singleton na úrovni modulu:
def self.config
@config ||= Folio::Tiptap::Config.new
end
Folio::Tiptap::Model#tiptap_config (app/models/concerns/folio/tiptap/model.rb:182) vrací přesně tuto jednu instanci každému modelu, který si metodu nepřepíše:
def tiptap_config(attribute_name: nil)
Folio::Tiptap.config
end
Problém
Všechny modely bez vlastního tiptap_config tedy sdílejí jednu společnou mutovatelnou instanci Config. To má dva nepříjemné důsledky:
- Únik mutací mezi modely. Pokud jakýkoli kód změní vrácený objekt na místě (
config.node_names << ..., node_names.delete, schema= apod.), změna se projeví u všech modelů bez přepisu a přetrvá po celou dobu běhu procesu. Jde o tichou chybu — nikde se neprojeví výjimkou, jen se „nečekaně“ objeví/zmizí node v editoru jiného modelu.
- Modely bez přepisu dědí kompletní auto-discovered sadu nodů.
Config.new bez node_names spadne do get_all_tiptap_node_names, což přes glob app/models/**/tiptap/node/**/*.rb načte úplně všechny nody (v naší aplikaci včetně Paywall::Delimiter).
Tohle už nás v aplikaci smilemusic stálo celou ladicí session (FUL-198): Paywall::Delimiter se objevoval v editorech MagazineIssue a Author, protože ty si tiptap_config nepřepisovaly a dostávaly tak globální sadu. Vyřešili jsme to na straně aplikace (přepis na curated Publisher.default_tiptap_config), ale samotný footgun ve foliu zůstává — příští model bez přepisu, případně první kód, který singleton zmutuje na místě, narazí na totéž.
Reprodukce
g = Folio::Tiptap.config
m1 = ModelBezPrepisu.new.tiptap_config
m1.object_id == g.object_id # => true (stejný objekt)
# Mutace u jednoho modelu prosákne ke všem ostatním:
m1.node_names << "Foo"
ModelBezPrepisu2.new.tiptap_config.node_names.include?("Foo") # => true
Návrhy řešení
Návrh A — dup při čtení (zpětně kompatibilní, nízké riziko)
tiptap_config (a/nebo Folio::Tiptap.config) vrací kopii s hluboce zkopírovanými mutovatelnými vnitřnostmi (node_names, node_groups, schema):
def tiptap_config(attribute_name: nil)
Folio::Tiptap.config.dup # + Config#initialize_copy, který kopíruje node_names atd.
end
- ✅ Stávající vzor
config.node_names << ... funguje dál beze změny.
- ✅ Mutace už neuniká mezi modely.
- ⚠️
dup musí být dostatečně hluboký (mělký dup sdílí pole) — je potřeba initialize_copy.
- ⚠️ Drobná režie na každé volání.
Návrh B — zmrazit singleton (hlasité selhání, drobně breaking)
Singleton i jeho node_names zmrazíme; jakákoli mutace na místě vyhodí FrozenError:
def self.config
@config ||= Folio::Tiptap::Config.new.freeze # + freeze node_names
end
- ✅ Tichou chybu mění na okamžitou, jasnou výjimku.
- ✅ Levné, žádná režie.
- ⚠️ Breaking pro kód, který dnes singleton mutuje (po našem auditu už to v smilemusic nikdo nedělá, ale jiné aplikace na foliu mohou). Nutí to k explicitní konfiguraci.
Návrh C — immutable Config + kompoziční API (nejčistší, větší refactor)
Config se stane immutable a doplní se neměnící skládání:
config = Folio::Tiptap.config.with(node_names: config.node_names + %w[Foo])
# nebo Folio::Tiptap.build_config(extra_nodes: [...])
- ✅ Architektonicky nejčistší, mutace jako vzor zcela mizí.
- ✅ Dá se zkombinovat s B (zmrazený základ +
#with).
- ⚠️ Vyžaduje úpravu všech volajících (v aplikacích
config.node_names << ... → config.with(...)).
Doporučení
Krátkodobě Návrh A (dup) — odstraní únik, nevyžaduje změny v aplikacích a dá se vydat hned. Dlouhodobě směřovat k B + C (zmrazený základ + kompoziční #with), aby celá třída těchto chyb selhávala hlasitě místo tichého prosakování. Oprava patří do folia, protože chrání všechny aplikace, ne jen smilemusic.
Samostatná, ale související otázka k rozmyšlení: má být výchozí konfigurace pro modely bez přepisu skutečně „všechny auto-discovered nody“, nebo by default měl být spíš prázdný/konzervativní a sady se měly přihlašovat explicitně?
Kontext
Folio::Tiptap.config(app/lib/folio/tiptap.rb:15) je memoizovaný singleton na úrovni modulu:Folio::Tiptap::Model#tiptap_config(app/models/concerns/folio/tiptap/model.rb:182) vrací přesně tuto jednu instanci každému modelu, který si metodu nepřepíše:Problém
Všechny modely bez vlastního
tiptap_configtedy sdílejí jednu společnou mutovatelnou instanciConfig. To má dva nepříjemné důsledky:config.node_names << ...,node_names.delete,schema=apod.), změna se projeví u všech modelů bez přepisu a přetrvá po celou dobu běhu procesu. Jde o tichou chybu — nikde se neprojeví výjimkou, jen se „nečekaně“ objeví/zmizí node v editoru jiného modelu.Config.newbeznode_namesspadne doget_all_tiptap_node_names, což přes globapp/models/**/tiptap/node/**/*.rbnačte úplně všechny nody (v naší aplikaci včetněPaywall::Delimiter).Tohle už nás v aplikaci smilemusic stálo celou ladicí session (FUL-198):
Paywall::Delimiterse objevoval v editorechMagazineIssueaAuthor, protože ty sitiptap_confignepřepisovaly a dostávaly tak globální sadu. Vyřešili jsme to na straně aplikace (přepis na curatedPublisher.default_tiptap_config), ale samotný footgun ve foliu zůstává — příští model bez přepisu, případně první kód, který singleton zmutuje na místě, narazí na totéž.Reprodukce
Návrhy řešení
Návrh A — dup při čtení (zpětně kompatibilní, nízké riziko)
tiptap_config(a/neboFolio::Tiptap.config) vrací kopii s hluboce zkopírovanými mutovatelnými vnitřnostmi (node_names,node_groups,schema):config.node_names << ...funguje dál beze změny.dupmusí být dostatečně hluboký (mělkýdupsdílí pole) — je potřebainitialize_copy.Návrh B — zmrazit singleton (hlasité selhání, drobně breaking)
Singleton i jeho
node_nameszmrazíme; jakákoli mutace na místě vyhodíFrozenError:Návrh C — immutable Config + kompoziční API (nejčistší, větší refactor)
Configse stane immutable a doplní se neměnící skládání:#with).config.node_names << ...→config.with(...)).Doporučení
Krátkodobě Návrh A (dup) — odstraní únik, nevyžaduje změny v aplikacích a dá se vydat hned. Dlouhodobě směřovat k B + C (zmrazený základ + kompoziční
#with), aby celá třída těchto chyb selhávala hlasitě místo tichého prosakování. Oprava patří do folia, protože chrání všechny aplikace, ne jen smilemusic.